ipcz: Refactor multinode tests

Changes how multinode tests are expressed.

Introduces a new TestNode class which creates and owns a single
configurable node and exposes facilities for launching and
interconnecting new nodes.

A new MULTINODE_TEST_NODE(fixture, name) macro can be used to
conveniently subclass TestNode with a particular body of logic,
and such subclasses can be launched as new nodes by other
TestNodes.

Separately, the MultinodeTest fixture must now be templated over
a TestNode subclass (which the MultinodeTest itself also inherits,
in addition to GTest interfaces), and TEST_P() invocations which use
it are now implicitly run as a broker node.

This framework allows these integration tests to be run in any
test configuration, including a (not yet implemented) mode where
each spawned node runs in an isolated process.

Bug: 1299283
Change-Id: Idd02c4c08b1911497ea0ef3998e92dd5580f459e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3665020
Reviewed-by: Alex Gough <ajgo@chromium.org>
Commit-Queue: Ken Rockot <rockot@google.com>
Cr-Commit-Position: refs/heads/main@{#1007942}
NOKEYCHECK=True
GitOrigin-RevId: 0d9e10fd2efb69a21203036db095ca6ab4997b10
diff --git a/src/box_test.cc b/src/box_test.cc
index 57ff373..32851e1 100644
--- a/src/box_test.cc
+++ b/src/box_test.cc
@@ -16,7 +16,8 @@
 namespace ipcz {
 namespace {
 
-using BoxTest = test::MultinodeTestWithDriver;
+using BoxTestNode = test::TestNode;
+using BoxTest = test::MultinodeTest<BoxTestNode>;
 
 using Blob = reference_drivers::Blob;
 
@@ -54,15 +55,13 @@
 }
 
 TEST_P(BoxTest, BoxAndUnbox) {
-  IpczHandle node = CreateBrokerNode();
-
   constexpr const char kMessage[] = "Hello, world?";
   IpczDriverHandle blob_handle =
       Blob::ReleaseAsHandle(MakeRefCounted<Blob>(kMessage));
 
   IpczHandle box;
   EXPECT_EQ(IPCZ_RESULT_OK,
-            ipcz().Box(node, blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
+            ipcz().Box(node(), blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
 
   blob_handle = IPCZ_INVALID_DRIVER_HANDLE;
   EXPECT_EQ(IPCZ_RESULT_OK,
@@ -70,37 +69,29 @@
 
   Ref<Blob> blob = Blob::TakeFromHandle(blob_handle);
   EXPECT_EQ(kMessage, blob->message());
-
-  Close(node);
 }
 
 TEST_P(BoxTest, CloseBox) {
-  IpczHandle node = CreateBrokerNode();
-
   Ref<Blob> blob = MakeRefCounted<Blob>("meh");
   Ref<Blob::RefCountedFlag> destroyed = blob->destruction_flag_for_testing();
   IpczDriverHandle blob_handle = Blob::ReleaseAsHandle(std::move(blob));
 
   IpczHandle box;
   EXPECT_EQ(IPCZ_RESULT_OK,
-            ipcz().Box(node, blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
+            ipcz().Box(node(), blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
 
   EXPECT_FALSE(destroyed->get());
   EXPECT_EQ(IPCZ_RESULT_OK, ipcz().Close(box, IPCZ_NO_FLAGS, nullptr));
   EXPECT_TRUE(destroyed->get());
-
-  Close(node);
 }
 
 TEST_P(BoxTest, Peek) {
-  IpczHandle node = CreateBrokerNode();
-
   constexpr const char kMessage[] = "Hello, world?";
   IpczDriverHandle blob_handle =
       Blob::ReleaseAsHandle(MakeRefCounted<Blob>(kMessage));
   IpczHandle box;
   EXPECT_EQ(IPCZ_RESULT_OK,
-            ipcz().Box(node, blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
+            ipcz().Box(node(), blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
 
   blob_handle = IPCZ_INVALID_DRIVER_HANDLE;
   EXPECT_EQ(IPCZ_RESULT_OK,
@@ -118,84 +109,105 @@
 
   Ref<Blob> released_blob = Blob::TakeFromHandle(blob_handle);
   EXPECT_EQ(blob, released_blob.get());
-
-  Close(node);
 }
 
-TEST_P(BoxTest, TransferBox) {
-  IpczHandle node0 = CreateBrokerNode();
-  IpczHandle node1 = CreateNonBrokerNode();
-  auto [a, b] = ConnectBrokerToNonBroker(node0, node1);
+constexpr const char kMessage1[] = "Hello, world?";
+constexpr const char kMessage2[] = "Hello, world!";
+constexpr const char kMessage3[] = "Hello! World!";
 
-  constexpr const char kMessage1[] = "Hello, world?";
-  constexpr const char kMessage2[] = "Hello, world!";
-  constexpr const char kMessage3[] = "Hello! World!";
-
-  IpczDriverHandle blob_handle = CreateTestBlob(kMessage1, kMessage2);
-  IpczHandle box;
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            ipcz().Box(node0, blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
-
-  Put(a, kMessage3, {&box, 1});
+MULTINODE_TEST_NODE(BoxTestNode, TransferBoxClient) {
+  IpczHandle b = ConnectToBroker();
 
   std::string message;
+  IpczHandle box;
   EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, &message, {&box, 1}));
   EXPECT_EQ(kMessage3, message);
 
+  IpczDriverHandle blob_handle;
   EXPECT_EQ(IPCZ_RESULT_OK,
             ipcz().Unbox(box, IPCZ_NO_FLAGS, nullptr, &blob_handle));
   EXPECT_TRUE(BlobContentsMatch(blob_handle, kMessage1, kMessage2));
 
-  CloseAll({a, b, node1, node0});
+  Close(b);
 }
 
-TEST_P(BoxTest, TransferBoxBetweenNonBrokers) {
-  IpczHandle node0 = CreateBrokerNode();
-  IpczHandle node1 = CreateNonBrokerNode();
-  IpczHandle node2 = CreateNonBrokerNode();
+TEST_P(BoxTest, TransferBox) {
+  IpczHandle c = SpawnTestNode<TransferBoxClient>();
 
-  auto [a, b] = ConnectBrokerToNonBroker(node0, node1);
-  auto [c, d] = ConnectBrokerToNonBroker(node0, node2);
+  IpczDriverHandle blob_handle = CreateTestBlob(kMessage1, kMessage2);
+  IpczHandle box;
+  EXPECT_EQ(IPCZ_RESULT_OK,
+            ipcz().Box(node(), blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
 
-  // Create a new portal pair and send each end to one of the two non-brokers so
-  // they'll establish a direct link.
-  auto [e, f] = OpenPortals(node0);
-  Put(a, "", {&e, 1});
-  Put(c, "", {&f, 1});
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c, kMessage3, {&box, 1}));
 
-  std::string message;
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, &message, {&e, 1}));
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(d, &message, {&f, 1}));
+  Close(c);
+}
 
-  constexpr const char kMessage1[] = "Hello, world?";
-  constexpr const char kMessage2[] = "Hello, world!";
-  constexpr const char kMessage3[] = "Hello! World!";
+constexpr size_t TransferBoxBetweenNonBrokersNumIterations = 50;
 
-  // Send messages end-to-end in each direction from one non-broker to the
-  // other, each carrying a box with some data and a driver object. This covers
-  // message relaying for multinode tests running with forced object brokering
-  // enabled.
-  constexpr size_t kNumIterations = 10;
-  for (size_t i = 0; i < kNumIterations; ++i) {
+MULTINODE_TEST_NODE(BoxTestNode, TransferBoxBetweenNonBrokersClient1) {
+  IpczHandle q;
+  IpczHandle b = ConnectToBroker();
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&q, 1}));
+
+  for (size_t i = 0; i < TransferBoxBetweenNonBrokersNumIterations; ++i) {
     IpczDriverHandle blob_handle = CreateTestBlob(kMessage1, kMessage2);
     IpczHandle box;
     EXPECT_EQ(IPCZ_RESULT_OK,
-              ipcz().Box(node0, blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
+              ipcz().Box(node(), blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(q, kMessage3, {&box, 1}));
+    box = IPCZ_INVALID_DRIVER_HANDLE;
 
-    const IpczHandle sender = i % 2 ? e : f;
-    const IpczHandle receiver = i % 2 ? f : e;
+    std::string message;
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(q, &message, {&box, 1}));
+    EXPECT_EQ(kMessage1, message);
+    EXPECT_EQ(IPCZ_RESULT_OK,
+              ipcz().Unbox(box, IPCZ_NO_FLAGS, nullptr, &blob_handle));
+    EXPECT_TRUE(BlobContentsMatch(blob_handle, kMessage2, kMessage3));
+  }
 
-    Put(sender, kMessage3, {&box, 1});
+  CloseAll({q, b});
+}
 
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(receiver, &message, {&box, 1}));
+MULTINODE_TEST_NODE(BoxTestNode, TransferBoxBetweenNonBrokersClient2) {
+  IpczHandle p;
+  IpczHandle b = ConnectToBroker();
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&p, 1}));
+
+  for (size_t i = 0; i < TransferBoxBetweenNonBrokersNumIterations; ++i) {
+    IpczHandle box;
+    IpczDriverHandle blob_handle;
+    std::string message;
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(p, &message, {&box, 1}));
     EXPECT_EQ(kMessage3, message);
-
     EXPECT_EQ(IPCZ_RESULT_OK,
               ipcz().Unbox(box, IPCZ_NO_FLAGS, nullptr, &blob_handle));
     EXPECT_TRUE(BlobContentsMatch(blob_handle, kMessage1, kMessage2));
+
+    blob_handle = CreateTestBlob(kMessage2, kMessage3);
+    EXPECT_EQ(IPCZ_RESULT_OK,
+              ipcz().Box(node(), blob_handle, IPCZ_NO_FLAGS, nullptr, &box));
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(p, kMessage1, {&box, 1}));
   }
 
-  CloseAll({a, b, c, d, e, f, node2, node1, node0});
+  CloseAll({p, b});
+}
+
+TEST_P(BoxTest, TransferBoxBetweenNonBrokers) {
+  IpczHandle c1 = SpawnTestNode<TransferBoxBetweenNonBrokersClient1>();
+  IpczHandle c2 = SpawnTestNode<TransferBoxBetweenNonBrokersClient2>();
+
+  // Create a new portal pair and send each end to one of the two non-brokers so
+  // they'll establish a direct link.
+  auto [q, p] = OpenPortals();
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c1, "", {&q, 1}));
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c2, "", {&p, 1}));
+
+  // Wait for the clients to finish their business and go away.
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(c1, IPCZ_TRAP_PEER_CLOSED));
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(c2, IPCZ_TRAP_PEER_CLOSED));
+  CloseAll({c1, c2});
 }
 
 INSTANTIATE_MULTINODE_TEST_SUITE_P(BoxTest);
diff --git a/src/connect_test.cc b/src/connect_test.cc
index 04334d9..1a717db 100644
--- a/src/connect_test.cc
+++ b/src/connect_test.cc
@@ -9,173 +9,121 @@
 #include "test/multinode_test.h"
 #include "test/test_transport_listener.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/abseil-cpp/absl/synchronization/notification.h"
 #include "third_party/abseil-cpp/absl/types/optional.h"
 
 namespace ipcz {
 namespace {
 
-using ConnectTest = test::MultinodeTestWithDriver;
+using ConnectTestNode = test::TestNode;
+using ConnectTest = test::MultinodeTest<ConnectTestNode>;
+
+MULTINODE_TEST_NODE(ConnectTestNode, BrokerToNonBrokerClient) {
+  IpczHandle b = ConnectToBroker();
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(b, IPCZ_TRAP_PEER_CLOSED));
+  Close(b);
+}
 
 TEST_P(ConnectTest, BrokerToNonBroker) {
-  IpczHandle broker = CreateBrokerNode();
-  IpczHandle non_broker = CreateNonBrokerNode();
+  IpczHandle c = SpawnTestNode<BrokerToNonBrokerClient>();
+  Close(c);
+}
 
-  IpczDriverHandle broker_transport;
-  IpczDriverHandle non_broker_transport;
-  CreateBrokerToNonBrokerTransports(&broker_transport, &non_broker_transport);
+constexpr size_t kNumBrokerPortals = 2;
+constexpr size_t kNumNonBrokerPortals = 5;
+static_assert(kNumBrokerPortals < kNumNonBrokerPortals,
+              "Test requires fewer broker portals than non-broker portals");
 
-  IpczHandle non_broker_portal;
-  ASSERT_EQ(IPCZ_RESULT_OK, ipcz().ConnectNode(non_broker, non_broker_transport,
-                                               1, IPCZ_CONNECT_NODE_TO_BROKER,
-                                               nullptr, &non_broker_portal));
+MULTINODE_TEST_NODE(ConnectTestNode, SurplusPortalsClient) {
+  IpczHandle portals[kNumNonBrokerPortals];
+  ConnectToBroker(portals);
 
-  IpczHandle broker_portal;
-  ASSERT_EQ(IPCZ_RESULT_OK,
-            ipcz().ConnectNode(broker, broker_transport, 1, IPCZ_NO_FLAGS,
-                               nullptr, &broker_portal));
-
-  Close(broker_portal);
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            WaitForConditionFlags(non_broker_portal, IPCZ_TRAP_PEER_CLOSED));
-
-  CloseAll({non_broker_portal, non_broker, broker});
+  // All of the surplus portals should observe peer closure.
+  for (size_t i = kNumBrokerPortals; i < kNumNonBrokerPortals; ++i) {
+    EXPECT_EQ(IPCZ_RESULT_OK,
+              WaitForConditionFlags(portals[i], IPCZ_TRAP_PEER_CLOSED));
+  }
+  CloseAll(portals);
 }
 
 TEST_P(ConnectTest, SurplusPortals) {
-  IpczHandle broker = CreateBrokerNode();
-  IpczHandle non_broker = CreateNonBrokerNode();
-
-  IpczDriverHandle broker_transport;
-  IpczDriverHandle non_broker_transport;
-  CreateBrokerToNonBrokerTransports(&broker_transport, &non_broker_transport);
-
-  constexpr size_t kNumBrokerPortals = 2;
-  constexpr size_t kNumNonBrokerPortals = 5;
-  static_assert(kNumBrokerPortals < kNumNonBrokerPortals,
-                "Test requires fewer broker portals than non-broker portals");
-
-  IpczHandle broker_portals[kNumBrokerPortals];
-  ASSERT_EQ(
-      IPCZ_RESULT_OK,
-      ipcz().ConnectNode(broker, broker_transport, std::size(broker_portals),
-                         IPCZ_NO_FLAGS, nullptr, broker_portals));
-
-  IpczHandle non_broker_portals[kNumNonBrokerPortals];
-  ASSERT_EQ(IPCZ_RESULT_OK, ipcz().ConnectNode(non_broker, non_broker_transport,
-                                               std::size(non_broker_portals),
-                                               IPCZ_CONNECT_NODE_TO_BROKER,
-                                               nullptr, non_broker_portals));
-
-  // All of the surplus broker portals should observe peer closure.
-  for (size_t i = kNumBrokerPortals; i < kNumNonBrokerPortals; ++i) {
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(non_broker_portals[i],
-                                                    IPCZ_TRAP_PEER_CLOSED));
-  }
-
-  for (IpczHandle portal : non_broker_portals) {
-    Close(portal);
-  }
-  for (IpczHandle portal : broker_portals) {
-    Close(portal);
-  }
-  CloseAll({non_broker, broker});
+  IpczHandle portals[kNumBrokerPortals];
+  SpawnTestNode<SurplusPortalsClient>(portals);
+  CloseAll(portals);
 }
 
-TEST_P(ConnectTest, DisconnectWithoutHandshake) {
-  IpczHandle broker = CreateBrokerNode();
-  IpczHandle non_broker = CreateNonBrokerNode();
-
-  // First fail to connect a broker.
-  IpczDriverHandle broker_transport, non_broker_transport;
-  CreateBrokerToNonBrokerTransports(&broker_transport, &non_broker_transport);
-
-  IpczHandle portal;
-  {
-    // This listener is scoped such that it closes the non-broker's transport
-    // after the broker issues its ConnectNode(). This should trigger a
-    // rejection and ultimately portal closure by the broker.
-    test::TestTransportListener non_broker_listener(non_broker,
-                                                    non_broker_transport);
-    non_broker_listener.DiscardMessages<msg::ConnectFromBrokerToNonBroker>();
-
-    ASSERT_EQ(IPCZ_RESULT_OK,
-              ipcz().ConnectNode(broker, broker_transport, 1, IPCZ_NO_FLAGS,
-                                 nullptr, &portal));
-  }
-
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            WaitForConditionFlags(portal, IPCZ_TRAP_PEER_CLOSED));
-  Close(portal);
-
-  // Next fail to connect a non-broker.
-  CreateBrokerToNonBrokerTransports(&broker_transport, &non_broker_transport);
-
-  {
-    // This listener is scoped such that it closes the broker transport after
-    // the non-broker issues its ConnectNode(). This should trigger a rejection
-    // and ultimately portal closure by the non-broker.
-    test::TestTransportListener broker_listener(broker, broker_transport);
-    broker_listener.DiscardMessages<msg::ConnectFromNonBrokerToBroker>();
-
-    ASSERT_EQ(
-        IPCZ_RESULT_OK,
-        ipcz().ConnectNode(non_broker, non_broker_transport, 1,
-                           IPCZ_CONNECT_NODE_TO_BROKER, nullptr, &portal));
-  }
-
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            WaitForConditionFlags(portal, IPCZ_TRAP_PEER_CLOSED));
-
-  CloseAll({portal, non_broker, broker});
+MULTINODE_TEST_NODE(ConnectTestNode, ExpectDisconnectFromBroker) {
+  IpczHandle b = ConnectToBroker();
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(b, IPCZ_TRAP_PEER_CLOSED));
+  Close(b);
 }
 
-TEST_P(ConnectTest, DisconnectOnBadMessage) {
-  IpczHandle broker = CreateBrokerNode();
-  IpczHandle non_broker = CreateNonBrokerNode();
+TEST_P(ConnectTest, DisconnectWithoutBrokerHandshake) {
+  TransportPair transports = CreateTransports();
+  auto controller =
+      SpawnTestNode<ExpectDisconnectFromBroker>(transports.theirs);
+  EXPECT_EQ(IPCZ_RESULT_OK,
+            GetDriver().Close(transports.ours, IPCZ_NO_FLAGS, nullptr));
+  controller->WaitForShutdown();
+}
 
-  IpczDriverHandle broker_transport, non_broker_transport;
-  CreateBrokerToNonBrokerTransports(&broker_transport, &non_broker_transport);
+MULTINODE_TEST_NODE(ConnectTestNode,
+                    DisconnectWithoutNonBrokerHandshakeClient) {
+  // Our transport is automatically closed on exit. No handshake is sent because
+  // we never call ConnectToBroker(). No action required.
+}
 
-  // First fail to connect a broker.
-  IpczHandle portal;
-  ASSERT_EQ(IPCZ_RESULT_OK,
-            ipcz().ConnectNode(broker, broker_transport, 1, IPCZ_NO_FLAGS,
-                               nullptr, &portal));
+TEST_P(ConnectTest, DisconnectWithoutNonBrokerHandshake) {
+  IpczHandle c = SpawnTestNode<DisconnectWithoutNonBrokerHandshakeClient>();
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(c, IPCZ_TRAP_PEER_CLOSED));
+  Close(c);
+}
 
-  test::TestTransportListener non_broker_listener(non_broker,
-                                                  non_broker_transport);
-  non_broker_listener.DiscardMessages<msg::ConnectFromBrokerToNonBroker>();
+TEST_P(ConnectTest, DisconnectOnBadBrokerMessage) {
+  TransportPair transports = CreateTransports();
+  auto controller =
+      SpawnTestNode<ExpectDisconnectFromBroker>(transports.theirs);
 
+  // Send some garbage to the other node.
   const char kBadMessage[] = "this will never be a valid handshake message!";
+  EXPECT_EQ(
+      IPCZ_RESULT_OK,
+      GetDriver().Transmit(transports.ours, kBadMessage, std::size(kBadMessage),
+                           nullptr, 0, IPCZ_NO_FLAGS, nullptr));
   EXPECT_EQ(IPCZ_RESULT_OK,
-            GetDriver().Transmit(non_broker_transport, kBadMessage,
-                                 std::size(kBadMessage), nullptr, 0,
-                                 IPCZ_NO_FLAGS, nullptr));
+            GetDriver().Close(transports.ours, IPCZ_NO_FLAGS, nullptr));
 
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            WaitForConditionFlags(portal, IPCZ_TRAP_PEER_CLOSED));
+  // The other node will only shut down once it's observed peer closure on its
+  // portal to us; which it should, because we just sent it some garbage.
+  controller->WaitForShutdown();
+}
 
-  non_broker_listener.StopListening();
-  Close(portal);
+MULTINODE_TEST_NODE(ConnectTestNode, TransmitSomeGarbage) {
+  // Instead of doing the usual connection dance, send some garbage back to the
+  // broker. It should disconnect ASAP.
+  const char kBadMessage[] = "this will never be a valid handshake message!";
+  EXPECT_EQ(
+      IPCZ_RESULT_OK,
+      GetDriver().Transmit(transport(), kBadMessage, std::size(kBadMessage),
+                           nullptr, 0, IPCZ_NO_FLAGS, nullptr));
 
-  // Next fail to connect a non-broker.
-  CreateBrokerToNonBrokerTransports(&broker_transport, &non_broker_transport);
+  test::TestTransportListener listener(node(), ReleaseTransport());
+  absl::Notification done;
+  listener.OnError([&done] { done.Notify(); });
+  done.WaitForNotification();
+  listener.StopListening();
+}
 
-  test::TestTransportListener broker_listener(broker, broker_transport);
-  broker_listener.DiscardMessages<msg::ConnectFromNonBrokerToBroker>();
+TEST_P(ConnectTest, DisconnectOnBadNonBrokerMessage) {
+  IpczHandle c;
+  auto controller = SpawnTestNode<TransmitSomeGarbage>({&c, 1});
 
-  ASSERT_EQ(IPCZ_RESULT_OK,
-            ipcz().ConnectNode(non_broker, non_broker_transport, 1,
-                               IPCZ_CONNECT_NODE_TO_BROKER, nullptr, &portal));
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            GetDriver().Transmit(broker_transport, kBadMessage,
-                                 std::size(kBadMessage), nullptr, 0,
-                                 IPCZ_NO_FLAGS, nullptr));
-  EXPECT_EQ(IPCZ_RESULT_OK,
-            WaitForConditionFlags(portal, IPCZ_TRAP_PEER_CLOSED));
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(c, IPCZ_TRAP_PEER_CLOSED));
+  Close(c);
 
-  broker_listener.StopListening();
-  CloseAll({portal, non_broker, broker});
+  // Make sure the client also observes disconnection of its transport. It won't
+  // terminate until that happens.
+  controller->WaitForShutdown();
 }
 
 INSTANTIATE_MULTINODE_TEST_SUITE_P(ConnectTest);
diff --git a/src/remote_portal_test.cc b/src/remote_portal_test.cc
index 45ecbdb..bbcef8d 100644
--- a/src/remote_portal_test.cc
+++ b/src/remote_portal_test.cc
@@ -2,6 +2,8 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+#include <string>
+#include <string_view>
 #include <utility>
 
 #include "ipcz/ipcz.h"
@@ -11,144 +13,214 @@
 namespace ipcz {
 namespace {
 
-using RemotePortalTest = test::MultinodeTestWithDriver;
+using RemotePortalTestNode = test::TestNode;
+using RemotePortalTest = test::MultinodeTest<RemotePortalTestNode>;
+
+static constexpr std::string_view kTestMessage1 = "hello world";
+static constexpr std::string_view kTestMessage2 = "hola mundo";
+
+MULTINODE_TEST_NODE(RemotePortalTestNode, BasicConnectionClient) {
+  IpczHandle b = ConnectToBroker();
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(b, kTestMessage1));
+
+  std::string message;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, &message));
+  EXPECT_EQ(kTestMessage2, message);
+  Close(b);
+}
 
 TEST_P(RemotePortalTest, BasicConnection) {
-  IpczHandle broker = CreateBrokerNode();
-  IpczHandle non_broker = CreateNonBrokerNode();
-  auto [a, b] = ConnectBrokerToNonBroker(broker, non_broker);
+  IpczHandle c = SpawnTestNode<BasicConnectionClient>();
 
-  VerifyEndToEnd(a, b);
+  std::string message;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(c, &message));
+  EXPECT_EQ(kTestMessage1, message);
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c, kTestMessage2));
+  Close(c);
+}
 
-  CloseAll({a, b, non_broker, broker});
+MULTINODE_TEST_NODE(RemotePortalTestNode, PortalTransferClient) {
+  IpczHandle b = ConnectToBroker();
+
+  IpczHandle p = IPCZ_INVALID_HANDLE;
+  std::string message;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, &message, {&p, 1}));
+  EXPECT_EQ(kTestMessage1, message);
+  EXPECT_NE(IPCZ_INVALID_HANDLE, p);
+
+  VerifyEndToEnd(p);
+  CloseAll({p, b});
 }
 
 TEST_P(RemotePortalTest, PortalTransfer) {
-  IpczHandle broker = CreateBrokerNode();
-  IpczHandle non_broker = CreateNonBrokerNode();
-  auto [a, b] = ConnectBrokerToNonBroker(broker, non_broker);
-  auto [c, d] = OpenPortals(broker);
+  IpczHandle c = SpawnTestNode<PortalTransferClient>();
 
-  // Send portal `d` to the non-broker node.
-  const std::string kMessage = "hello";
-  EXPECT_EQ(IPCZ_RESULT_OK, Put(a, kMessage, {&d, 1}));
-  d = IPCZ_INVALID_HANDLE;
+  auto [q, p] = OpenPortals();
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c, kTestMessage1, {&p, 1}));
 
-  // Retrieve portal `d` from the sent parcel.
-  std::string message;
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, &message, {&d, 1}));
-  EXPECT_EQ(kMessage, message);
-  EXPECT_NE(IPCZ_INVALID_HANDLE, d);
+  VerifyEndToEnd(q);
+  CloseAll({q, c});
+}
 
-  // Portals `c` and `d` should be able to communicate end-to-end across the
-  // node boundary.
-  VerifyEndToEnd(c, d);
+constexpr size_t kMultipleHopsNumIterations = 100;
 
-  CloseAll({a, b, c, d, non_broker, broker});
+MULTINODE_TEST_NODE(RemotePortalTestNode, MultipleHopsClient1) {
+  IpczHandle b = ConnectToBroker();
+
+  auto [q, p] = OpenPortals();
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(b, "", {&p, 1}));
+
+  for (size_t i = 0; i < kMultipleHopsNumIterations; ++i) {
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(q, kTestMessage2));
+  }
+
+  for (size_t i = 0; i < kMultipleHopsNumIterations; ++i) {
+    std::string message;
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(q, &message));
+    EXPECT_EQ(kTestMessage1, message);
+  }
+
+  CloseAll({q, b});
+}
+
+MULTINODE_TEST_NODE(RemotePortalTestNode, MultipleHopsClient2) {
+  IpczHandle b = ConnectToBroker();
+
+  IpczHandle p;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&p, 1}));
+
+  for (size_t i = 0; i < kMultipleHopsNumIterations; ++i) {
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(p, kTestMessage1));
+  }
+
+  for (size_t i = 0; i < kMultipleHopsNumIterations; ++i) {
+    std::string message;
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(p, &message));
+    EXPECT_EQ(kTestMessage2, message);
+  }
+
+  CloseAll({p, b});
 }
 
 TEST_P(RemotePortalTest, MultipleHops) {
-  IpczHandle node0 = CreateBrokerNode();
-  IpczHandle node1 = CreateNonBrokerNode();
-  IpczHandle node2 = CreateNonBrokerNode();
+  IpczHandle c1 = SpawnTestNode<MultipleHopsClient1>();
+  IpczHandle c2 = SpawnTestNode<MultipleHopsClient2>();
 
-  auto [a, b] = ConnectBrokerToNonBroker(node0, node1);
-  auto [c, d] = ConnectBrokerToNonBroker(node0, node2);
-  auto [e, f] = OpenPortals(node1);
+  IpczHandle p;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(c1, nullptr, {&p, 1}));
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c2, "", {&p, 1}));
 
-  // Send `f` from node1 to node0 and then from node0 to node2
-  Put(b, "here", {&f, 1});
-  f = IPCZ_INVALID_HANDLE;
+  CloseAll({c1, c2});
+}
 
-  std::string message;
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(a, &message, {&f, 1}));
-  ASSERT_NE(IPCZ_INVALID_HANDLE, f);
+constexpr size_t kTransferBackAndForthNumIterations = 1;
 
-  Put(c, "ok ok", {&f, 1});
-  f = IPCZ_INVALID_HANDLE;
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(d, &message, {&f, 1}));
-  ASSERT_NE(IPCZ_INVALID_HANDLE, f);
+MULTINODE_TEST_NODE(RemotePortalTestNode, TransferBackAndForthClient) {
+  IpczHandle b = ConnectToBroker();
 
-  constexpr size_t kNumIterations = 100;
-  for (size_t i = 0; i < kNumIterations; ++i) {
-    Put(e, "merp");
-    Put(f, "nerp");
-  }
-  for (size_t i = 0; i < kNumIterations; ++i) {
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(f, &message));
-    EXPECT_EQ("merp", message);
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(e, &message));
-    EXPECT_EQ("nerp", message);
+  for (size_t i = 0; i < kTransferBackAndForthNumIterations; ++i) {
+    IpczHandle p;
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&p, 1}));
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(b, "", {&p, 1}));
   }
 
-  CloseAll({a, b, c, d, e, f, node2, node1, node0});
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(b, IPCZ_TRAP_PEER_CLOSED));
+  Close(b);
 }
 
 TEST_P(RemotePortalTest, TransferBackAndForth) {
-  IpczHandle node0 = CreateBrokerNode();
-  IpczHandle node1 = CreateNonBrokerNode();
+  IpczHandle c = SpawnTestNode<TransferBackAndForthClient>();
 
-  auto [a, b] = ConnectBrokerToNonBroker(node0, node1);
-  auto [c, d] = OpenPortals(node0);
-
+  constexpr std::string_view kMessage = "hihihihi";
+  auto [q, p] = OpenPortals();
   std::string message;
-  constexpr size_t kNumIterations = 8;
-  for (size_t i = 0; i < kNumIterations; ++i) {
-    Put(c, "hi");
-    Put(a, "", {&d, 1});
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, &message, {&d, 1}));
-    Put(b, "", {&d, 1});
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(a, &message, {&d, 1}));
-    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(d, &message));
-    EXPECT_EQ("hi", message);
+  for (size_t i = 0; i < kTransferBackAndForthNumIterations; ++i) {
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(q, kMessage));
+    EXPECT_EQ(IPCZ_RESULT_OK, Put(c, "", {&p, 1}));
+    p = IPCZ_INVALID_HANDLE;
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(c, nullptr, {&p, 1}));
+    EXPECT_NE(IPCZ_INVALID_HANDLE, p);
+    EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(p, &message));
+    EXPECT_EQ(kMessage, message);
   }
 
-  CloseAll({a, b});
-  VerifyEndToEnd(c, d);
+  VerifyEndToEndLocal(q, p);
+  CloseAll({q, p, c});
+}
 
-  CloseAll({c, d, node1, node0});
+MULTINODE_TEST_NODE(RemotePortalTestNode, DisconnectThroughProxyClient1) {
+  IpczHandle b = ConnectToBroker();
+
+  IpczHandle q;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&q, 1}));
+
+  // Should eventually be observed by virtue of the forced disconnection of
+  // client 3 in the main test body.
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(q, IPCZ_TRAP_PEER_CLOSED));
+  CloseAll({q, b});
+}
+
+MULTINODE_TEST_NODE(RemotePortalTestNode, DisconnectThroughProxyClient2) {
+  IpczHandle b = ConnectToBroker();
+
+  // Receive a portal p from the broker and bounce it back.
+  IpczHandle p;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&p, 1}));
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(b, "", {&p, 1}));
+
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(b, IPCZ_TRAP_PEER_CLOSED));
+  Close(b);
+}
+
+MULTINODE_TEST_NODE(RemotePortalTestNode, DisconnectThroughProxyClient3) {
+  IpczHandle b = ConnectToBroker();
+
+  // Receive a portal p from the broker and then immediately terminate this
+  // node.
+  IpczHandle p;
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&p, 1}));
+
+  // Forcibly shut this node down, severing all connections to other nodes. We
+  // do this *before* closing `p` to ensure that we are't just exercising normal
+  // portal closure, which other tests already cover. From the perspective of
+  // other nodes, we are effectively simulating a crash of this node.
+  CloseThisNode();
+
+  // It's still necessary to explicitly close local portals after the node
+  // itself has been shut down. Otherwise they'd leak.
+  CloseAll({p, b});
 }
 
 TEST_P(RemotePortalTest, DisconnectThroughProxy) {
   // Exercises node disconnection. Namely if portals on nodes 1 and 3 are
   // connected via proxy on node 2, and node 3 disappears, node 1's portal
   // should observe peer closure.
-  IpczHandle node0 = CreateBrokerNode();
-  IpczHandle node1 = CreateNonBrokerNode();
-  IpczHandle node2 = CreateNonBrokerNode();
-  IpczHandle node3 = CreateNonBrokerNode();
+  IpczHandle c1, c3;
+  auto c1_control = SpawnTestNode<DisconnectThroughProxyClient1>({&c1, 1});
+  IpczHandle c2 = SpawnTestNode<DisconnectThroughProxyClient2>();
+  auto c3_control = SpawnTestNode<DisconnectThroughProxyClient3>({&c3, 1});
 
-  auto [a, b] = ConnectBrokerToNonBroker(node0, node1);
-  auto [c, d] = ConnectBrokerToNonBroker(node0, node2);
-  auto [e, f] = ConnectBrokerToNonBroker(node0, node3);
+  auto [q, p] = OpenPortals();
 
-  auto [q, p] = OpenPortals(node0);
+  // We send q to client 1, and p to client 2.
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c1, "", {&q, 1}));
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c2, "", {&p, 1}));
 
-  // Send `q` to `node1` and `p` to `node2`.
-  Put(a, "", {&q, 1});
-  Put(c, "", {&p, 1});
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(b, nullptr, {&q, 1}));
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(d, nullptr, {&p, 1}));
+  // Client 2 forwards p back to us, and we forward it now to client 3. This
+  // process ensures that client 2 will for some time serve as a proxy between
+  // client 1 and client 3.
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(c2, nullptr, {&p, 1}));
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(c3, "", {&p, 1}));
 
-  // Now forward 'p' back to `node0` and then again to `node3`. This ensures
-  // that node2 will proxy between node1 and node3 for at least a small window
-  // of time.
-  Put(d, "", {&p, 1});
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(c, nullptr, {&p, 1}));
-  Put(e, "", {&p, 1});
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(f, nullptr, {&p, 1}));
+  // Client 3 will terminate on its own. Though not determinstic, this will
+  // race with proxy reduction such that client 2 may still be proxying for the
+  // q-p portal pair when client 3 (who owns p) goes away.
+  EXPECT_TRUE(c3_control->WaitForShutdown());
 
-  // Forcibly close node3 such that all its connections are severed. Any portals
-  // reliant on those connections should observe peer closure as a result. Note
-  // that portal lifetime is independent of node lifetime, so affected portals
-  // created by node3 still must be explicitly closed below.
-  Close(node3);
-
-  // Even q must observe peer closure, despite being potentially several hops
-  // away from node3 where its peer p resided.
-  EXPECT_EQ(IPCZ_RESULT_OK, WaitForConditionFlags(q, IPCZ_TRAP_PEER_CLOSED));
-
-  CloseAll({a, b, c, d, e, f, q, p, node2, node1, node0});
+  // Client 1 waits on q observing peer closure before terminating. This will
+  // block until that happens.
+  EXPECT_TRUE(c1_control->WaitForShutdown());
+  CloseAll({c1, c2, c3});
 }
 
 INSTANTIATE_MULTINODE_TEST_SUITE_P(RemotePortalTest);
diff --git a/src/test/multinode_test.cc b/src/test/multinode_test.cc
index f348657..88eaf71 100644
--- a/src/test/multinode_test.cc
+++ b/src/test/multinode_test.cc
@@ -4,91 +4,187 @@
 
 #include "test/multinode_test.h"
 
+#include <thread>
+
 #include "ipcz/ipcz.h"
 #include "reference_drivers/single_process_reference_driver.h"
 #include "third_party/abseil-cpp/absl/base/macros.h"
+#include "third_party/abseil-cpp/absl/types/optional.h"
+#include "third_party/abseil-cpp/absl/types/variant.h"
+#include "util/log.h"
 
 namespace ipcz::test {
 
 namespace {
 
-const IpczDriver* GetDriverImpl(MultinodeTest::DriverMode mode) {
-  switch (mode) {
-    case MultinodeTest::DriverMode::kSync:
-      return &reference_drivers::kSingleProcessReferenceDriver;
+// Launches a new node on a dedicated thread within the same process. All
+// connections use the synchronous single-process driver.
+class InProcessTestNodeController : public TestNode::TestNodeController {
+ public:
+  InProcessTestNodeController(DriverMode driver_mode,
+                              std::unique_ptr<TestNode> test_node)
+      : client_thread_(absl::in_place,
+                       &RunTestNode,
+                       driver_mode,
+                       std::move(test_node)) {}
 
-    default:
-      // Other modes not yet supported.
-      return nullptr;
+  ~InProcessTestNodeController() override { ABSL_ASSERT(!client_thread_); }
+
+  // TestNode::TestNodeController:
+  bool WaitForShutdown() override {
+    if (client_thread_) {
+      client_thread_->join();
+      client_thread_.reset();
+    }
+
+    // In spirit, the point of WaitForShutdown()'s return value is to signal to
+    // the running test whether something went wrong in a spawned node. This is
+    // necessary to propagate test expectation failures from within child
+    // processes when running in a multiprocess test mode.
+    //
+    // When spawned nodes are running in the main test process however, their
+    // test expectation failures already affect the pass/fail state of the
+    // running test. In this case there's no need to propagate a redundant
+    // failure signal here, hence we always return true.
+    return true;
   }
-}
 
-void DoConnect(const IpczAPI& ipcz,
-               IpczHandle node,
-               IpczDriverHandle transport,
-               IpczConnectNodeFlags flags,
-               IpczHandle& portal) {
-  const IpczResult result =
-      ipcz.ConnectNode(node, transport, 1, flags, nullptr, &portal);
-  ASSERT_EQ(IPCZ_RESULT_OK, result);
-}
+ private:
+  static void RunTestNode(DriverMode driver_mode,
+                          std::unique_ptr<TestNode> test_node) {
+    test_node->Initialize(driver_mode, IPCZ_NO_FLAGS);
+    test_node->NodeBody();
+  }
+
+  absl::optional<std::thread> client_thread_;
+};
 
 }  // namespace
 
-MultinodeTest::MultinodeTest() = default;
+TestNode::~TestNode() {
+  for (auto& spawned_node : spawned_nodes_) {
+    EXPECT_TRUE(spawned_node->WaitForShutdown());
+  }
 
-MultinodeTest::~MultinodeTest() = default;
+  // If we never connected to the broker, make sure we don't leak our transport.
+  if (transport_ != IPCZ_INVALID_DRIVER_HANDLE) {
+    GetDriver().Close(transport_, IPCZ_NO_FLAGS, nullptr);
+  }
 
-const IpczDriver& MultinodeTest::GetDriver(DriverMode mode) const {
-  const IpczDriver* driver = GetDriverImpl(mode);
-  ABSL_ASSERT(driver);
-  return *driver;
+  CloseThisNode();
 }
 
-IpczHandle MultinodeTest::CreateBrokerNode(DriverMode mode) {
-  IpczHandle node;
-  ipcz().CreateNode(&GetDriver(mode), IPCZ_INVALID_DRIVER_HANDLE,
-                    IPCZ_CREATE_NODE_AS_BROKER, nullptr, &node);
-  return node;
+const IpczDriver& TestNode::GetDriver() const {
+  static IpczDriver kInvalidDriver = {};
+  switch (driver_mode_) {
+    case DriverMode::kSync:
+      return reference_drivers::kSingleProcessReferenceDriver;
+
+    default:
+      // Other modes not yet supported.
+      ABSL_ASSERT(false);
+      return kInvalidDriver;
+  }
 }
 
-IpczHandle MultinodeTest::CreateNonBrokerNode(DriverMode mode) {
-  IpczHandle node;
-  ipcz().CreateNode(&GetDriver(mode), IPCZ_INVALID_DRIVER_HANDLE, IPCZ_NO_FLAGS,
-                    nullptr, &node);
-  return node;
-}
+void TestNode::Initialize(DriverMode driver_mode,
+                          IpczCreateNodeFlags create_node_flags) {
+  driver_mode_ = driver_mode;
 
-void MultinodeTest::CreateBrokerToNonBrokerTransports(
-    DriverMode mode,
-    IpczDriverHandle* transport0,
-    IpczDriverHandle* transport1) {
-  // TODO: Support other DriverModes.
-  ABSL_ASSERT(mode == DriverMode::kSync);
-  IpczResult result = GetDriver(mode).CreateTransports(
-      IPCZ_INVALID_DRIVER_HANDLE, IPCZ_INVALID_DRIVER_HANDLE, IPCZ_NO_FLAGS,
-      nullptr, transport0, transport1);
+  ABSL_ASSERT(node_ == IPCZ_INVALID_HANDLE);
+  const IpczResult result =
+      ipcz().CreateNode(&GetDriver(), IPCZ_INVALID_DRIVER_HANDLE,
+                        create_node_flags, nullptr, &node_);
   ABSL_ASSERT(result == IPCZ_RESULT_OK);
 }
 
-std::pair<IpczHandle, IpczHandle> MultinodeTest::ConnectBrokerToNonBroker(
-    DriverMode mode,
-    IpczHandle broker_node,
-    IpczHandle non_broker_node) {
-  IpczDriverHandle broker_transport;
-  IpczDriverHandle non_broker_transport;
-  CreateBrokerToNonBrokerTransports(mode, &broker_transport,
-                                    &non_broker_transport);
+void TestNode::ConnectToBroker(absl::Span<IpczHandle> portals) {
+  IpczDriverHandle transport =
+      std::exchange(transport_, IPCZ_INVALID_DRIVER_HANDLE);
+  ABSL_ASSERT(transport != IPCZ_INVALID_DRIVER_HANDLE);
+  const IpczResult result =
+      ipcz().ConnectNode(node(), transport, portals.size(),
+                         IPCZ_CONNECT_NODE_TO_BROKER, nullptr, portals.data());
+  ASSERT_EQ(IPCZ_RESULT_OK, result);
+}
 
-  IpczHandle broker_portal;
-  DoConnect(ipcz(), broker_node, broker_transport, IPCZ_NO_FLAGS,
-            broker_portal);
+IpczHandle TestNode::ConnectToBroker() {
+  IpczHandle portal;
+  ConnectToBroker({&portal, 1});
+  return portal;
+}
 
-  IpczHandle non_broker_portal;
-  DoConnect(ipcz(), non_broker_node, non_broker_transport,
-            IPCZ_CONNECT_NODE_TO_BROKER, non_broker_portal);
+std::pair<IpczHandle, IpczHandle> TestNode::OpenPortals() {
+  return TestBase::OpenPortals(node_);
+}
 
-  return {broker_portal, non_broker_portal};
+void TestNode::CloseThisNode() {
+  if (node_ != IPCZ_INVALID_HANDLE) {
+    IpczHandle node = std::exchange(node_, IPCZ_INVALID_HANDLE);
+    ipcz().Close(node, IPCZ_NO_FLAGS, nullptr);
+  }
+}
+
+Ref<TestNode::TestNodeController> TestNode::SpawnTestNodeImpl(
+    IpczHandle from_node,
+    const internal::TestNodeDetails& details,
+    PortalsOrTransport portals_or_transport) {
+  struct Connect {
+    explicit Connect(TestNode& test) : test(test) {}
+
+    IpczDriverHandle operator()(absl::Span<IpczHandle> portals) {
+      TransportPair transports = test.CreateTransports();
+      const IpczResult result =
+          test.ipcz().ConnectNode(test.node(), transports.ours, portals.size(),
+                                  IPCZ_NO_FLAGS, nullptr, portals.data());
+      ABSL_ASSERT(result == IPCZ_RESULT_OK);
+      return transports.theirs;
+    }
+
+    IpczDriverHandle operator()(IpczDriverHandle transport) {
+      return transport;
+    }
+
+    TestNode& test;
+  };
+
+  Connect connect(*this);
+  IpczDriverHandle their_transport = absl::visit(connect, portals_or_transport);
+
+  // TODO: Support a multiprocess mode which launches the new node in a child
+  // child process, passing the transport there.
+  std::unique_ptr<TestNode> test_node = details.factory();
+  test_node->SetTransport(their_transport);
+  Ref<TestNodeController> controller =
+      MakeRefCounted<InProcessTestNodeController>(driver_mode_,
+                                                  std::move(test_node));
+  spawned_nodes_.push_back(controller);
+  return controller;
+}
+
+TestNode::TransportPair TestNode::CreateTransports() {
+  TransportPair transports;
+  switch (driver_mode_) {
+    case DriverMode::kSync: {
+      const IpczResult result =
+          reference_drivers::kSingleProcessReferenceDriver.CreateTransports(
+              IPCZ_INVALID_DRIVER_HANDLE, IPCZ_INVALID_DRIVER_HANDLE,
+              IPCZ_NO_FLAGS, nullptr, &transports.ours, &transports.theirs);
+      ABSL_ASSERT(result == IPCZ_RESULT_OK);
+      break;
+    }
+
+    default:
+      LOG(FATAL) << "DriverMode not yet supported.";
+      return {};
+  }
+
+  return transports;
+}
+
+void TestNode::SetTransport(IpczDriverHandle transport) {
+  ABSL_ASSERT(transport_ == IPCZ_INVALID_DRIVER_HANDLE);
+  transport_ = transport;
 }
 
 }  // namespace ipcz::test
diff --git a/src/test/multinode_test.h b/src/test/multinode_test.h
index 5c60daf..99508f7 100644
--- a/src/test/multinode_test.h
+++ b/src/test/multinode_test.h
@@ -5,119 +5,260 @@
 #ifndef IPCZ_SRC_TEST_MULTINODE_TEST_H_
 #define IPCZ_SRC_TEST_MULTINODE_TEST_H_
 
+#include <memory>
+#include <string>
+#include <string_view>
+#include <type_traits>
+#include <vector>
+
 #include "ipcz/ipcz.h"
 #include "test/test_base.h"
 #include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/abseil-cpp/absl/base/macros.h"
+#include "third_party/abseil-cpp/absl/types/span.h"
+#include "third_party/abseil-cpp/absl/types/variant.h"
+#include "util/ref_counted.h"
 
 namespace ipcz::test {
 
-// Base test fixture to support tests which exercise behavior across multiple
-// ipcz nodes. These may be single-process on a synchronous driver,
-// single-process on an asynchronous (e.g. multiprocess) driver, or fully
-// multiprocess.
+class TestNode;
+
+template <typename TestNodeType>
+class MultinodeTest;
+
+// Selects which driver test nodes will use. Interconnecting nodes must always
+// use the same driver.
 //
-// This fixture mostly provides convenience methods for creating and connecting
-// nodes in various useful configurations.
-class MultinodeTest : public internal::TestBase, public ::testing::Test {
- public:
-  // Selects which driver a new node will use. Interconnecting nodes must always
-  // use the same driver.
-  //
-  // Multinode tests are parameterized over these modes to provide coverage of
-  // various interesting constraints encountered in production. Some platforms
-  // require driver objects to be relayed through a broker. Some environments
-  // prevent nodes from allocating their own shared memory regions.
-  //
-  // Incongruity between synchronous and asynchronous test failures generally
-  // indicates race conditions within ipcz, but many bugs will cause failures in
-  // all driver modes. The synchronous version is deterministic and generally
-  // easier to debug in such cases.
-  enum class DriverMode {
-    // Use the fully synchronous, single-process reference driver. This driver
-    // does not create any background threads and all ipcz operations will
-    // complete synchronously from end-to-end.
-    kSync,
+// Multinode tests are parameterized over these modes to provide coverage of
+// various interesting constraints encountered in production. Some platforms
+// require driver objects to be relayed through a broker. Some environments
+// prevent nodes from allocating their own shared memory regions.
+//
+// Incongruity between synchronous and asynchronous test failures generally
+// indicates race conditions within ipcz, but many bugs will cause failures in
+// all driver modes. The synchronous version is generally easier to debug in
+// such cases.
+enum class DriverMode {
+  // Use the fully synchronous, single-process reference driver. This driver
+  // does not create any background threads and all ipcz operations will
+  // complete synchronously from end-to-end.
+  kSync,
 
-    // Use the async multiprocess driver as-is. All nodes can allocate their own
-    // shared memory directly through the driver.
-    kAsync,
+  // Use the async multiprocess driver as-is. All nodes can allocate their own
+  // shared memory directly through the driver.
+  kAsync,
 
-    // Use the async multiprocess driver, and force non-broker nodes to delegate
-    // shared memory allocation to their broker.
-    kAsyncDelegatedAlloc,
+  // Use the async multiprocess driver, and force non-broker nodes to delegate
+  // shared memory allocation to their broker.
+  kAsyncDelegatedAlloc,
 
-    // Use the async multiprocess driver, and force non-broker-to-non-broker
-    // transmission of driver objects to be relayed through a broker. All nodes
-    // can allocate their own shared memory directly through the driver.
-    kAsyncObjectBrokering,
+  // Use the async multiprocess driver, and force non-broker-to-non-broker
+  // transmission of driver objects to be relayed through a broker. All nodes
+  // can allocate their own shared memory directly through the driver.
+  kAsyncObjectBrokering,
 
-    // Use the async multiprocess driver, forcing shared memory AND driver
-    // object relay both to be delegated to a broker.
-    kAsyncObjectBrokeringAndDelegatedAlloc,
-  };
-
-  MultinodeTest();
-  ~MultinodeTest() override;
-
-  const IpczDriver& GetDriver(DriverMode mode) const;
-
-  // Creates a new broker node using the given DriverMode.
-  IpczHandle CreateBrokerNode(DriverMode mode);
-
-  // Creates a new broker node using the given DriverMode.
-  IpczHandle CreateNonBrokerNode(DriverMode mode);
-
-  // Creates a pair of transports for the given driver mode.
-  void CreateBrokerToNonBrokerTransports(
-      DriverMode mode,
-      IpczDriverHandle* broker_transport,
-      IpczDriverHandle* non_broker_transport);
-
-  std::pair<IpczHandle, IpczHandle> ConnectBrokerToNonBroker(
-      DriverMode mode,
-      IpczHandle broker_node,
-      IpczHandle non_broker_node);
+  // Use the async multiprocess driver, forcing shared memory AND driver
+  // object relay both to be delegated to a broker.
+  kAsyncObjectBrokeringAndDelegatedAlloc,
 };
 
-// Helper for a MultinodeTest parameterized over DriverMode. Most integration
-// tests should use this for parameterization.
-class MultinodeTestWithDriver
-    : public MultinodeTest,
-      public testing::WithParamInterface<MultinodeTest::DriverMode> {
+namespace internal {
+
+using TestNodeFactory = std::unique_ptr<TestNode> (*)();
+
+template <typename TestNodeType>
+std::unique_ptr<TestNode> MakeTestNode() {
+  return std::make_unique<TestNodeType>();
+}
+
+// Type used to package metadata about a MULTINODE_TEST_NODE() invocation.
+struct TestNodeDetails {
+  const std::string_view name;
+  const TestNodeFactory factory;
+};
+
+template <typename T>
+static constexpr bool IsValidTestNodeType = std::is_base_of_v<TestNode, T>;
+
+}  // namespace internal
+
+// Base class to support tests which exercise behavior across multiple ipcz
+// nodes. These may be single-process on a synchronous driver, single-process on
+// an asynchronous (e.g. multiprocess) driver, or fully multiprocess.
+//
+// This class provides convenience methods for creating and connecting nodes
+// in various useful configurations. Note that it does NOT inherit from GTest's
+// Test class, as multiple instances may run in parallel for a single test, and
+// GTest's Test class is not compatible with that behavior.
+//
+// Instead, while MULTINODE_TEST_NODE() invocations should be based directly on
+// TestNode or a derivative thereof. TEST_P() invocations for multinode tests
+// should be based on derivatives of MultinodeTest<T> (see below this class),
+// where T itself is a TestNode or some derivative thereof.
+//
+// This arrangement allows the main test body and its related
+// MULTINODE_TEST_NODE() invocations to be based on the same essential type,
+// making multinode tests easier to read and write.
+class TestNode : public internal::TestBase {
  public:
-  const IpczDriver& GetDriver() const {
-    return MultinodeTest::GetDriver(GetParam());
+  // Exposes interaction with one node spawned by another.
+  class TestNodeController : public RefCounted {
+   public:
+    // Blocks until the spawned node has terminated. Returns true if the node
+    // executed and terminated cleanly, or false if it encountered at least one
+    // test expectation failure while running.
+    virtual bool WaitForShutdown() = 0;
+  };
+
+  virtual ~TestNode();
+
+  // Handle to this node.
+  IpczHandle node() const { return node_; }
+
+  // Handle to this node's broker-facing transport, if and only if
+  // ConnectToBroker() hasn't been called yet.
+  IpczDriverHandle transport() const { return transport_; }
+
+  // Releases transport() to the caller. After calling this, it is no longer
+  // valid to call either transport() or ConnectToBroker(), and this fixture
+  // will not automatically close the transport on destruction.
+  IpczDriverHandle ReleaseTransport() {
+    return std::exchange(transport_, IPCZ_INVALID_DRIVER_HANDLE);
   }
 
-  IpczHandle CreateBrokerNode() {
-    return MultinodeTest::CreateBrokerNode(GetParam());
+  // The driver currently in use. Selected by test parameter.
+  const IpczDriver& GetDriver() const;
+
+  // One-time initialization. Called internally during test setup. Should never
+  // be called by individual test code.
+  void Initialize(DriverMode driver_mode,
+                  IpczCreateNodeFlags create_node_flags);
+
+  // May be called at most once by the TestNode body, to connect initial
+  // `portals` to the broker.
+  void ConnectToBroker(absl::Span<IpczHandle> portals);
+
+  // Shorthand for the above, for the common case with only one initial portal.
+  IpczHandle ConnectToBroker();
+
+  // Opens a new portal pair on this node.
+  std::pair<IpczHandle, IpczHandle> OpenPortals();
+
+  // Spawns a new test node of TestNodeType and populates `portals` with a set
+  // of initial portals connected to the node, via a new transport.
+  template <typename TestNodeType>
+  Ref<TestNodeController> SpawnTestNode(absl::Span<IpczHandle> portals) {
+    return SpawnTestNodeImpl(node_, TestNodeType::kDetails, portals);
   }
 
-  IpczHandle CreateNonBrokerNode() {
-    return MultinodeTest::CreateNonBrokerNode(GetParam());
+  // Shorthand for the above, for the common case with only one initial portal
+  // and no need for the test body to retain a controller for the node.
+  template <typename TestNodeType>
+  IpczHandle SpawnTestNode() {
+    IpczHandle portal;
+    SpawnTestNode<TestNodeType>({&portal, 1});
+    return portal;
   }
 
-  void CreateBrokerToNonBrokerTransports(
-      IpczDriverHandle* broker_transport,
-      IpczDriverHandle* non_broker_transport) {
-    MultinodeTest::CreateBrokerToNonBrokerTransports(
-        GetParam(), broker_transport, non_broker_transport);
+  // Spawns a new test node of TestNodeType, giving it `transport` to use for
+  // its broker connection. The caller is resposible for the other end of that
+  // connection.
+  template <typename TestNodeType>
+  Ref<TestNodeController> SpawnTestNode(IpczDriverHandle transport) {
+    return SpawnTestNodeImpl(node_, TestNodeType::kDetails, transport);
   }
 
-  std::pair<IpczHandle, IpczHandle> ConnectBrokerToNonBroker(
-      IpczHandle broker_node,
-      IpczHandle non_broker_node) {
-    return MultinodeTest::ConnectBrokerToNonBroker(GetParam(), broker_node,
-                                                   non_broker_node);
+  // Forcibly closes this Node, severing all links to other nodes and implicitly
+  // disconnecting any portals which relied on those links.
+  void CloseThisNode();
+
+  // The TestNode body provided by a MULTINODE_TEST_NODE() invocation. For main
+  // test definitions via TEST_P() with a MultinodeTest<T> fixture, this is
+  // unused in favor of TestBody().
+  virtual void NodeBody() {}
+
+  // Creates a pair of transports appropriate for connecting this (broker or
+  // non-broker) node to another non-broker node. Most tests should not use this
+  // directly, but should instead connect to other nodes using the more
+  // convenient helpers ConnectToBroker() or SpawnTestNode().
+  struct TransportPair {
+    IpczDriverHandle ours;
+    IpczDriverHandle theirs;
+  };
+  TransportPair CreateTransports();
+
+ private:
+  // Sets the transport to use when connecting to a broker via ConnectBroker.
+  // Must only be called once.
+  void SetTransport(IpczDriverHandle transport);
+
+  // Spawns a new node using an appropriate configuration for the current
+  // driver. Returns a controller which can be used to interact with the node
+  // outside of ipcz (e.g. to wait on its termination). `factory` is a function
+  // which can produce an in-process instance of the TestNode; `test_node_name`
+  // is a string which can be used to run the same TestNode subclass in a child
+  // process.
+  //
+  // If `portals_or_transport` is a span of IpczHandles, this creates a new
+  // pair of transports. One is given to the new node for connection back to us,
+  // and the other is connected immediately by the broker, filling in the
+  // handles with initial portals for the connection.
+  //
+  // Otherwise it's assumed to be a transport that will be given to the new
+  // node for connecting back to us. In this case the caller is responsible for
+  // the transport's peer.
+  using PortalsOrTransport =
+      absl::variant<absl::Span<IpczHandle>, IpczDriverHandle>;
+  Ref<TestNodeController> SpawnTestNodeImpl(
+      IpczHandle from_node,
+      const internal::TestNodeDetails& details,
+      PortalsOrTransport portals_or_transport);
+
+  DriverMode driver_mode_ = DriverMode::kSync;
+  IpczHandle node_ = IPCZ_INVALID_HANDLE;
+  IpczDriverHandle transport_ = IPCZ_INVALID_DRIVER_HANDLE;
+  std::vector<Ref<TestNodeController>> spawned_nodes_;
+};
+
+// Actual parameterized GTest Test fixture for multinode tests. This or a
+// subclass of it is required for TEST_P() invocations to function as proper
+// multinode tests.
+template <typename TestNodeType = TestNode>
+class MultinodeTest : public TestNodeType,
+                      public ::testing::Test,
+                      public ::testing::WithParamInterface<DriverMode> {
+ public:
+  static_assert(internal::IsValidTestNodeType<TestNodeType>,
+                "MultinodeTest<T> requires T to be a subclass of TestNode.");
+  MultinodeTest() {
+    TestNode::Initialize(GetParam(), IPCZ_CREATE_NODE_AS_BROKER);
   }
 };
 
 }  // namespace ipcz::test
 
+// Defines the main body of a non-broker test node for a multinode test. The
+// named node can be spawned by another node using SpawnTestNode<T> where T is
+// the unique name given by `node_name` here. `fixture` must be
+/// ipcz::test::TestNode or a subclass thereof.
+#define MULTINODE_TEST_NODE(fixture, node_name)                            \
+  class node_name : public fixture {                                       \
+    static_assert(::ipcz::test::internal::IsValidTestNodeType<fixture>,    \
+                  "MULTINODE_TEST_NODE() requires a fixture derived from " \
+                  "ipcz::test::TestNode.");                                \
+                                                                           \
+   public:                                                                 \
+    static constexpr ::ipcz::test::internal::TestNodeDetails kDetails = {  \
+        .name = #fixture "_" #node_name "_Node",                           \
+        .factory = &::ipcz::test::internal::MakeTestNode<node_name>,       \
+    };                                                                     \
+    void NodeBody() override;                                              \
+  };                                                                       \
+  void node_name::NodeBody()
+
 // TODO: Add other DriverMode enumerators here as support is landed.
 #define INSTANTIATE_MULTINODE_TEST_SUITE_P(suite) \
-  INSTANTIATE_TEST_SUITE_P(                       \
-      , suite,                                    \
-      ::testing::Values(ipcz::test::MultinodeTest::DriverMode::kSync))
+  INSTANTIATE_TEST_SUITE_P(, suite,               \
+                           ::testing::Values(ipcz::test::DriverMode::kSync))
 
 #endif  // IPCZ_SRC_TEST_MULTINODE_TEST_H_
diff --git a/src/test/test_base.cc b/src/test/test_base.cc
index db98271..5bc6674 100644
--- a/src/test/test_base.cc
+++ b/src/test/test_base.cc
@@ -43,7 +43,7 @@
   ASSERT_EQ(IPCZ_RESULT_OK, ipcz().Close(handle, IPCZ_NO_FLAGS, nullptr));
 }
 
-void TestBase::CloseAll(const std::vector<IpczHandle>& handles) {
+void TestBase::CloseAll(absl::Span<const IpczHandle> handles) {
   for (IpczHandle handle : handles) {
     Close(handle);
   }
@@ -155,7 +155,15 @@
   return Get(portal, message, handles);
 }
 
-void TestBase::VerifyEndToEnd(IpczHandle a, IpczHandle b) {
+void TestBase::VerifyEndToEnd(IpczHandle portal) {
+  static const char kTestMessage[] = "Ping!!!";
+  std::string message;
+  EXPECT_EQ(IPCZ_RESULT_OK, Put(portal, kTestMessage));
+  EXPECT_EQ(IPCZ_RESULT_OK, WaitToGet(portal, &message));
+  EXPECT_EQ(kTestMessage, message);
+}
+
+void TestBase::VerifyEndToEndLocal(IpczHandle a, IpczHandle b) {
   const std::string kMessage1 = "psssst";
   const std::string kMessage2 = "ssshhh";
 
diff --git a/src/test/test_base.h b/src/test/test_base.h
index 14dbc84..b9e7cc1 100644
--- a/src/test/test_base.h
+++ b/src/test/test_base.h
@@ -8,7 +8,6 @@
 #include <functional>
 #include <string_view>
 #include <utility>
-#include <vector>
 
 #include "ipcz/ipcz.h"
 #include "testing/gtest/include/gtest/gtest.h"
@@ -18,6 +17,12 @@
 
 // Base class for ipcz unit tests (see ipcz::test::Test in test.h) and multinode
 // test fixtures (see ipcz::test::MultinodeTest in multinode_test.h).
+//
+// Test fixtures should never derive from this class directly. For unit tests,
+// use ipcz::test::Test as a base. For multinode tests, use ipcz::test:TestNode
+// as a base for MULTINODE_TEST_NODE() invocations, and use
+// ipcz::test::MultinodeTest<T> (where T is a subclass of TestNode) for
+// TEST_P() invocations for parameterized multinode test bodies.
 class TestBase {
  public:
   using TrapEventHandler = std::function<void(const IpczTrapEvent&)>;
@@ -27,34 +32,67 @@
 
   const IpczAPI& ipcz() const { return ipcz_; }
 
-  // Some shorthand methods to access the ipcz API more conveniently.
+  // Some trivial shorthand methods to access the ipcz API more conveniently.
   void Close(IpczHandle handle);
-  void CloseAll(const std::vector<IpczHandle>& handles);
+  void CloseAll(absl::Span<const IpczHandle> handles);
   IpczHandle CreateNode(const IpczDriver& driver,
                         IpczCreateNodeFlags flags = IPCZ_NO_FLAGS);
   std::pair<IpczHandle, IpczHandle> OpenPortals(IpczHandle node);
   IpczResult Put(IpczHandle portal,
                  std::string_view message,
                  absl::Span<IpczHandle> handles = {});
+
+  // Shorthand for ipcz Get() to retrieve the next available parcel from
+  // `portal`.If no parcel is available, or any other condition prevents Get()
+  // from succeeding, this returns the same result as the ipcz Get() API.
+  //
+  // Assuming a parcel is available:
+  //
+  // If the parcel has data, it's stored  as a string in `*message` iff
+  // `message` is non-null. Any handles are stored in `handles` if it's large
+  // enough to hold all handles in the parcel.
+  //
+  // If the available parcel has data but `message` is null, or if the parcel
+  // carries has more handles than the capacity of `handles`, the parcel is
+  // not retrieved, and this returns IPCZ_RESULT_RESOURCE_EXHAUSTED, like the
+  // ipcz Get() API itself.
   IpczResult Get(IpczHandle portal,
                  std::string* message = nullptr,
                  absl::Span<IpczHandle> handles = {});
+
+  // Shorthand for the icpz Trap() API with convenient lambda support.
   IpczResult Trap(IpczHandle portal,
                   const IpczTrapConditions& conditions,
                   TrapEventHandler fn,
                   IpczTrapConditionFlags* flags = nullptr,
                   IpczPortalStatus* status = nullptr);
+
+  // Blocks until one or more conditions indicated by `conditions` are met by
+  // `portal`. For simple flag-only conditions like peer closure,
+  // WaitForConditionFlags() below may be used instead.
   IpczResult WaitForConditions(IpczHandle portal,
                                const IpczTrapConditions& conditions);
+
+  // Blocks until one or more conditions indicated by `flags` are met by
+  // `portal`. For parameterized conditions, use WaitForConditions() above.
   IpczResult WaitForConditionFlags(IpczHandle portal,
                                    IpczTrapConditionFlags flags);
+
+  // Waits to receive any parcel on portal.
   IpczResult WaitToGet(IpczHandle portal,
                        std::string* message = nullptr,
                        absl::Span<IpczHandle> handles = {});
 
-  // Sends a parcel from each of two portals and waits for them to be received
-  // by each other.
-  void VerifyEndToEnd(IpczHandle a, IpczHandle b);
+  // Sends a parcel from `portal` and expects to receive a parcel back with the
+  // same contents. Typical usage is to call this from two different nodes, on
+  // a pair of connected portals, in order to verify working communication
+  // between them.
+  void VerifyEndToEnd(IpczHandle portal);
+
+  // Similar to above, but useful in unit tests, or situations where both
+  // portals are local to the same node. In this case, a message is put into
+  // both `a` and `b`, and then this waits to read the same message from both.
+  void VerifyEndToEndLocal(IpczHandle a, IpczHandle b);
 
  private:
   static void HandleEvent(const IpczTrapEvent* event);