QBONE Server Simulator: Implement open-source BasicQuicServer

Fills a similar role as quic::QuicServer and quic::test::QuicTestServer but with even lower-level control and less assumptions that it is being used with HTTP.  Also, because this is only for test/experimentation purposes, directly integrates with threading functionality similar to quic::test::ServerThread.

This class is not necessarily QBONE-specific if somebody wants to do the work of better aligning it with the existing test QUIC server classes.

PiperOrigin-RevId: 929451174
diff --git a/build/source_list.bzl b/build/source_list.bzl
index ebc17ad..9c579af 100644
--- a/build/source_list.bzl
+++ b/build/source_list.bzl
@@ -1758,6 +1758,7 @@
     "quic/qbone/qbone_server_session.h",
     "quic/qbone/qbone_session_base.h",
     "quic/qbone/qbone_stream.h",
+    "quic/qbone/test_tools/basic_quic_server.h",
 ]
 qbone_srcs = [
     "quic/qbone/bonnet/icmp_reachable.cc",
@@ -1795,6 +1796,7 @@
     "quic/qbone/qbone_session_test.cc",
     "quic/qbone/qbone_stream.cc",
     "quic/qbone/qbone_stream_test.cc",
+    "quic/qbone/test_tools/basic_quic_server.cc",
 ]
 blind_sign_auth_hdrs = [
     "blind_sign_auth/blind_sign_auth.h",
diff --git a/build/source_list.gni b/build/source_list.gni
index 0bc2ac7..b6a23cf 100644
--- a/build/source_list.gni
+++ b/build/source_list.gni
@@ -1763,6 +1763,7 @@
     "src/quiche/quic/qbone/qbone_server_session.h",
     "src/quiche/quic/qbone/qbone_session_base.h",
     "src/quiche/quic/qbone/qbone_stream.h",
+    "src/quiche/quic/qbone/test_tools/basic_quic_server.h",
 ]
 qbone_srcs = [
     "src/quiche/quic/qbone/bonnet/icmp_reachable.cc",
@@ -1800,6 +1801,7 @@
     "src/quiche/quic/qbone/qbone_session_test.cc",
     "src/quiche/quic/qbone/qbone_stream.cc",
     "src/quiche/quic/qbone/qbone_stream_test.cc",
+    "src/quiche/quic/qbone/test_tools/basic_quic_server.cc",
 ]
 blind_sign_auth_hdrs = [
     "src/quiche/blind_sign_auth/blind_sign_auth.h",
diff --git a/build/source_list.json b/build/source_list.json
index 1b3cbe7..d0589ee 100644
--- a/build/source_list.json
+++ b/build/source_list.json
@@ -1761,7 +1761,8 @@
     "quiche/quic/qbone/qbone_packet_writer.h",
     "quiche/quic/qbone/qbone_server_session.h",
     "quiche/quic/qbone/qbone_session_base.h",
-    "quiche/quic/qbone/qbone_stream.h"
+    "quiche/quic/qbone/qbone_stream.h",
+    "quiche/quic/qbone/test_tools/basic_quic_server.h"
   ],
   "qbone_srcs": [
     "quiche/quic/qbone/bonnet/icmp_reachable.cc",
@@ -1798,7 +1799,8 @@
     "quiche/quic/qbone/qbone_session_base.cc",
     "quiche/quic/qbone/qbone_session_test.cc",
     "quiche/quic/qbone/qbone_stream.cc",
-    "quiche/quic/qbone/qbone_stream_test.cc"
+    "quiche/quic/qbone/qbone_stream_test.cc",
+    "quiche/quic/qbone/test_tools/basic_quic_server.cc"
   ],
   "blind_sign_auth_hdrs": [
     "quiche/blind_sign_auth/blind_sign_auth.h",
diff --git a/quiche/quic/qbone/test_tools/basic_quic_server.cc b/quiche/quic/qbone/test_tools/basic_quic_server.cc
new file mode 100644
index 0000000..00d58bf
--- /dev/null
+++ b/quiche/quic/qbone/test_tools/basic_quic_server.cc
@@ -0,0 +1,596 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "quiche/quic/qbone/test_tools/basic_quic_server.h"
+
+#include <array>
+#include <cstddef>
+#include <memory>
+#include <queue>
+#include <string>
+#include <thread>  // NOLINT (for open-sourceable thread ID)
+#include <utility>
+#include <vector>
+
+#include "absl/base/attributes.h"
+#include "absl/base/nullability.h"
+#include "absl/base/thread_annotations.h"
+#include "absl/container/flat_hash_map.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/str_cat.h"
+#include "absl/strings/string_view.h"
+#include "absl/synchronization/mutex.h"
+#include "absl/synchronization/notification.h"
+#include "absl/types/span.h"
+#include "quiche/quic/core/connection_id_generator.h"
+#include "quiche/quic/core/crypto/proof_source.h"
+#include "quiche/quic/core/crypto/quic_crypto_server_config.h"
+#include "quiche/quic/core/crypto/quic_random.h"
+#include "quiche/quic/core/deterministic_connection_id_generator.h"
+#include "quiche/quic/core/io/quic_default_event_loop.h"
+#include "quiche/quic/core/io/quic_event_loop.h"
+#include "quiche/quic/core/io/quic_server_io_harness.h"
+#include "quiche/quic/core/io/socket.h"
+#include "quiche/quic/core/quic_alarm_factory.h"
+#include "quiche/quic/core/quic_connection.h"
+#include "quiche/quic/core/quic_connection_id.h"
+#include "quiche/quic/core/quic_crypto_server_stream_base.h"
+#include "quiche/quic/core/quic_default_clock.h"
+#include "quiche/quic/core/quic_default_connection_helper.h"
+#include "quiche/quic/core/quic_dispatcher.h"
+#include "quiche/quic/core/quic_session.h"
+#include "quiche/quic/core/quic_stream.h"
+#include "quiche/quic/core/quic_stream_sequencer.h"
+#include "quiche/quic/core/quic_time.h"
+#include "quiche/quic/core/quic_types.h"
+#include "quiche/quic/core/quic_utils.h"
+#include "quiche/quic/core/quic_version_manager.h"
+#include "quiche/quic/core/quic_versions.h"
+#include "quiche/quic/platform/api/quic_socket_address.h"
+#include "quiche/quic/tools/quic_simple_crypto_server_stream_helper.h"
+#include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/platform/api/quiche_thread.h"
+#include "quiche/common/quiche_callbacks.h"
+#include "quiche/common/quiche_mem_slice.h"
+#include "quiche/common/quiche_socket_address.h"
+#include "quiche/common/quiche_status_utils.h"
+
+namespace quic::test {
+namespace {
+
+using ::quiche::QuicheMemSlice;
+using ::quiche::QuicheSocketAddress;
+using ::quiche::QuicheThread;
+using ::quiche::SingleUseCallback;
+
+constexpr auto kSupportedQuicVersions = std::to_array<ParsedQuicVersion>({
+    ParsedQuicVersion::RFCv1(),
+});
+
+class Stream : public QuicStream {
+ public:
+  Stream(QuicStreamId id,
+         QuicSession* absl_nonnull session ABSL_ATTRIBUTE_LIFETIME_BOUND,
+         BasicQuicServer::Handler* absl_nonnull handler
+             ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : QuicStream(id, session, /*is_static=*/false,
+                   QuicUtils::GetStreamType(
+                       id, session->connection()->perspective(),
+                       session->IsIncomingStream(id), session->version())),
+        handler_(handler) {
+    // This stream allows leaving data unconsumed in the sequencer, so need to
+    // use level-triggered reading to notify the handler on any increase in
+    // readable bytes.
+    sequencer()->set_level_triggered(true);
+  }
+
+  void OnDataAvailable() override {
+    int consumed = handler_->OnStreamDataAvailable(session()->connection_id(),
+                                                   id(), *sequencer());
+    if (consumed > 0) {
+      sequencer()->MarkConsumed(consumed);
+    }
+  }
+
+ private:
+  BasicQuicServer::Handler* absl_nonnull const handler_;
+};
+
+class Session : public QuicSession {
+ public:
+  class DestructionObserver {
+   public:
+    virtual ~DestructionObserver() = default;
+
+    virtual void OnSessionDestroyed(Session* session) = 0;
+  };
+
+  Session(std::unique_ptr<QuicConnection> absl_nonnull connection,
+          Visitor* visitor, const QuicConfig& config,
+          const QuicCryptoServerConfig* crypto_config,
+          BasicQuicServer::Handler* absl_nonnull handler
+              ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : QuicSession(connection.get(), visitor, config,
+                    connection->supported_versions(),
+                    /*num_expected_unidirectional_static_streams=*/0),
+        connection_(std::move(connection)),
+        handler_(handler) {
+    crypto_stream_ = handler_->OnNewSession(this, crypto_config);
+    Initialize();
+  }
+
+  ~Session() override {
+    handler_->OnSessionEnd(connection_id());
+
+    if (destruction_observer_) {
+      destruction_observer_->OnSessionDestroyed(this);
+    }
+  }
+
+  void RegisterDestructionObserver(DestructionObserver* absl_nonnull observer) {
+    QUICHE_CHECK(!destruction_observer_);
+    destruction_observer_ = observer;
+  }
+
+  void UnregisterDestructionObserver() {
+    QUICHE_CHECK(destruction_observer_);
+    destruction_observer_ = nullptr;
+  }
+
+  // QuicSession:
+
+  const QuicCryptoStream* GetCryptoStream() const override {
+    return crypto_stream_.get();
+  }
+
+  std::vector<absl::string_view>::const_iterator SelectAlpn(
+      const std::vector<absl::string_view>& alpns) const override {
+    return handler_->SelectAlpn(connection_id(), alpns);
+  }
+
+ protected:
+  QuicStream* CreateIncomingStream(QuicStreamId id) override {
+    if (handler_->OnNewStream(connection_id(), id)) {
+      auto stream = std::make_unique<Stream>(id, this, handler_);
+      Stream* stream_ptr = stream.get();
+      ActivateStream(std::move(stream));
+      return stream_ptr;
+    } else {
+      return nullptr;
+    }
+  }
+
+  QuicCryptoStream* GetMutableCryptoStream() override {
+    return crypto_stream_.get();
+  }
+
+  bool ShouldKeepConnectionAlive() const override { return true; }
+
+  void OnDatagramReceived(absl::string_view datagram) override {
+    absl::Span<const std::byte> datagram_bytes(
+        reinterpret_cast<const std::byte*>(datagram.data()), datagram.size());
+    handler_->OnDatagramReceived(connection_id(), datagram_bytes);
+  }
+
+ private:
+  std::unique_ptr<QuicConnection> connection_;
+  std::unique_ptr<QuicCryptoStream> crypto_stream_;
+  DestructionObserver* absl_nullable destruction_observer_ = nullptr;
+  BasicQuicServer::Handler* absl_nonnull const handler_;
+};
+
+class Dispatcher : public QuicDispatcher, public Session::DestructionObserver {
+ public:
+  Dispatcher(
+      const QuicConfig* config ABSL_ATTRIBUTE_LIFETIME_BOUND,
+      const QuicCryptoServerConfig* crypto_config ABSL_ATTRIBUTE_LIFETIME_BOUND,
+      QuicVersionManager* version_manager ABSL_ATTRIBUTE_LIFETIME_BOUND,
+      std::unique_ptr<QuicAlarmFactory> alarm_factory,
+      ConnectionIdGeneratorInterface* absl_nonnull connection_id_generator
+          ABSL_ATTRIBUTE_LIFETIME_BOUND,
+      BasicQuicServer::Handler* absl_nonnull handler
+          ABSL_ATTRIBUTE_LIFETIME_BOUND)
+      : QuicDispatcher(config, crypto_config, version_manager,
+                       std::make_unique<QuicDefaultConnectionHelper>(),
+                       std::make_unique<QuicSimpleCryptoServerStreamHelper>(),
+                       std::move(alarm_factory), kQuicDefaultConnectionIdLength,
+                       *connection_id_generator),
+        handler_(handler) {}
+
+  ~Dispatcher() override {
+    // Stop sessions from notifying back to this dispatcher on destruction.
+    for (auto& [connection_id, session] : sessions_) {
+      session->UnregisterDestructionObserver();
+    }
+  }
+
+  absl::StatusOr<Session*> GetSession(QuicConnectionId connection_id) {
+    auto it = sessions_.find(connection_id);
+    if (it == sessions_.end()) {
+      return absl::NotFoundError("Session not found.");
+    } else {
+      return it->second;
+    }
+  }
+
+  // Session::DestructionObserver
+  void OnSessionDestroyed(Session* session) override {
+    QUICHE_CHECK_EQ(sessions_.erase(session->connection_id()), 1);
+  }
+
+ protected:
+  std::unique_ptr<QuicSession> CreateQuicSession(
+      QuicConnectionId server_connection_id,
+      const QuicSocketAddress& self_address,
+      const QuicSocketAddress& peer_address, absl::string_view alpn,
+      const ParsedQuicVersion& version, const ParsedClientHello& parsed_chlo,
+      ConnectionIdGeneratorInterface& connection_id_generator) override {
+    QUICHE_LOG(INFO) << "Creating QuicSession for connection "
+                     << server_connection_id << " with peer "
+                     << peer_address.ToString() << " and ALPN " << alpn;
+
+    auto connection = std::make_unique<QuicConnection>(
+        server_connection_id, self_address, peer_address, helper(),
+        alarm_factory(), writer(),
+        /*owns_writer=*/false, Perspective::IS_SERVER,
+        ParsedQuicVersionVector{version}, connection_id_generator);
+
+    auto session = std::make_unique<Session>(
+        std::move(connection), this, config(), crypto_config(), handler_);
+    auto [it, inserted] =
+        sessions_.emplace(server_connection_id, session.get());
+    QUICHE_CHECK(inserted);
+    session->RegisterDestructionObserver(this);
+
+    return session;
+  }
+
+ private:
+  // Non-refed Session pointers to allow destruction, but expect the Session to
+  // call OnSessionDestroyed to remove itself.
+  absl::flat_hash_map<QuicConnectionId, Session*, QuicConnectionIdHash>
+      sessions_;
+
+  BasicQuicServer::Handler* absl_nonnull const handler_;
+};
+
+}  // namespace
+
+class BasicQuicServer::ServerThread : public QuicheThread {
+ public:
+  ServerThread(QuicheSocketAddress socket_address,
+               const QuicConfig* absl_nonnull config
+                   ABSL_ATTRIBUTE_LIFETIME_BOUND,
+               const QuicCryptoServerConfig* absl_nonnull crypto_config
+                   ABSL_ATTRIBUTE_LIFETIME_BOUND,
+               std::unique_ptr<BasicQuicServer::Handler> absl_nonnull handler)
+      : QuicheThread("BasicQuicServerThread"),
+        socket_address_(std::move(socket_address)),
+        config_(config),
+        crypto_config_(crypto_config),
+        handler_(std::move(handler)) {}
+
+  ~ServerThread() override {
+    // Expect/require server to be stopped before destruction.
+    QUICHE_CHECK(!running_notification_.HasBeenNotified() ||
+                 shutdown_notification_.HasBeenNotified());
+    QUICHE_CHECK(!dispatcher_);
+    QUICHE_CHECK(!event_loop_);
+  }
+
+  void Run() override {
+    QUICHE_CHECK(!running_notification_.HasBeenNotified());
+    QUICHE_CHECK(!shutdown_notification_.HasBeenNotified());
+
+    thread_id_ = std::this_thread::get_id();
+
+    QUICHE_CHECK(absl::IsUnknown(initialization_status_));
+    initialization_status_ = Initialize();
+    if (!initialization_status_.ok()) {
+      QUICHE_LOG(ERROR) << "Failed to initialize BasicQuicServer: "
+                        << initialization_status_;
+      running_notification_.Notify();
+      return;
+    }
+
+    bound_address_ = io_harness_->local_address();
+
+    QUICHE_LOG(INFO) << "Starting BasicQuicServer on " << bound_address_;
+    running_notification_.Notify();
+    while (!shutdown_notification_.HasBeenNotified()) {
+      ExecuteScheduledActions();
+      event_loop_->RunEventLoopOnce(QuicTimeDelta::FromMilliseconds(50));
+    }
+    thread_id_ = std::thread::id();
+
+    bound_address_ = QuicheSocketAddress();
+
+    dispatcher_->Shutdown();
+    io_harness_.reset();
+    socket_fd_.reset();
+    dispatcher_.reset();
+
+    // Don't reset `event_loop_`, as off-thread callers may attempt to use it
+    // to call WakeUp().
+  }
+
+  absl::Status WaitForRunning() {
+    running_notification_.WaitForNotification();
+    QUICHE_CHECK(!absl::IsUnknown(initialization_status_));
+    return initialization_status_;
+  }
+
+  void Shutdown() {
+    QUICHE_CHECK(running_notification_.HasBeenNotified());
+    QUICHE_CHECK(!shutdown_notification_.HasBeenNotified());
+    QUICHE_CHECK(!IsRunningOnThread());
+
+    shutdown_notification_.Notify();
+    if (event_loop_->SupportsWakeUp()) {
+      event_loop_->WakeUp();
+    }
+
+    Join();
+  }
+
+  absl::StatusOr<QuicheSocketAddress> bound_address() const {
+    QUICHE_CHECK(IsRunningOnThread());
+
+    return bound_address_;
+  }
+
+  absl::StatusOr<int> SendStreamData(QuicConnectionId server_connection_id,
+                                     QuicStreamId stream_id,
+                                     absl::Span<QuicheMemSlice> data,
+                                     bool fin) {
+    QUICHE_CHECK(IsRunningOnThread());
+    QUICHE_CHECK(dispatcher_);
+
+    QUICHE_ASSIGN_OR_RETURN(QuicSession * session,
+                            dispatcher_->GetSession(server_connection_id));
+
+    QuicStream* stream = session->GetActiveStream(stream_id);
+    if (!stream) {
+      return absl::NotFoundError("Stream not found.");
+    }
+    return stream->WriteMemSlices(data, fin).bytes_consumed;
+  }
+
+  absl::Status SendDatagram(QuicConnectionId server_connection_id,
+                            absl::Span<QuicheMemSlice> data) {
+    QUICHE_CHECK(IsRunningOnThread());
+    QUICHE_CHECK(dispatcher_);
+
+    QUICHE_ASSIGN_OR_RETURN(QuicSession * session,
+                            dispatcher_->GetSession(server_connection_id));
+
+    if (!session->connection() || !session->connection()->connected()) {
+      return absl::FailedPreconditionError("Session is not connected.");
+    }
+
+    DatagramResult result = session->SendDatagram(data);
+    if (result.status == DATAGRAM_STATUS_SUCCESS) {
+      return absl::OkStatus();
+    } else {
+      return absl::InternalError(absl::StrCat(
+          "Failed to send datagram: ", DatagramStatusToString(result.status)));
+    }
+  }
+
+  void Schedule(quiche::SingleUseCallback<void()> callback) {
+    QUICHE_CHECK(running_notification_.HasBeenNotified());
+    QUICHE_CHECK(!shutdown_notification_.HasBeenNotified());
+
+    {
+      absl::MutexLock lock(scheduled_actions_mutex_);
+      scheduled_actions_.push(std::move(callback));
+    }
+
+    if (IsRunningOnThread()) {
+      // Run all scheduled actions immediately if already on the server thread.
+      // This is important to avoid deadlocks if the caller of Schedule() waits
+      // on signals contained in the callback.
+      ExecuteScheduledActions();
+    } else if (event_loop_->SupportsWakeUp()) {
+      event_loop_->WakeUp();
+    }
+  }
+
+  void ScheduleAndWaitForCompletion(
+      quiche::SingleUseCallback<void()> callback) {
+    QUICHE_CHECK(running_notification_.HasBeenNotified());
+    QUICHE_CHECK(!shutdown_notification_.HasBeenNotified());
+
+    if (IsRunningOnThread()) {
+      ExecuteScheduledActions();
+      std::move(callback)();
+    } else {
+      absl::Notification done;
+      Schedule([&callback, &done]() {
+        std::move(callback)();
+        done.Notify();
+      });
+      done.WaitForNotification();
+    }
+  }
+
+  bool IsRunningOnThread() const {
+    return running_notification_.HasBeenNotified() &&
+           thread_id_ == std::this_thread::get_id();
+  }
+
+ private:
+  absl::Status Initialize() {
+    event_loop_ = GetDefaultEventLoop()->Create(QuicDefaultClock::Get());
+    dispatcher_ =
+        std::make_unique<Dispatcher>(config_, crypto_config_, &version_manager_,
+                                     event_loop_->CreateAlarmFactory(),
+                                     &connection_id_generator_, handler_.get());
+
+    QUICHE_ASSIGN_OR_RETURN(socket_fd_,
+                            CreateAndBindServerSocket(socket_address_));
+    QUICHE_ASSIGN_OR_RETURN(
+        io_harness_,
+        QuicServerIoHarness::Create(event_loop_.get(), dispatcher_.get(),
+                                    socket_fd_.get()));
+    io_harness_->InitializeWriter();
+
+    return absl::OkStatus();
+  }
+
+  void ExecuteScheduledActions() {
+    QUICHE_CHECK(IsRunningOnThread());
+
+    std::queue<quiche::SingleUseCallback<void()>> actions;
+    {
+      absl::MutexLock lock(scheduled_actions_mutex_);
+      actions.swap(scheduled_actions_);
+    }
+
+    while (!actions.empty() && !shutdown_notification_.HasBeenNotified()) {
+      std::move(actions.front())();
+      actions.pop();
+    }
+  }
+
+  const QuicheSocketAddress socket_address_;
+  const QuicConfig* absl_nonnull const config_;
+  const QuicCryptoServerConfig* absl_nonnull const crypto_config_;
+  const std::unique_ptr<BasicQuicServer::Handler> absl_nonnull handler_;
+
+  // Must be set before `running_notification_` is notified and not changed
+  // after that.
+  absl::Status initialization_status_ = absl::UnknownError("Not initialized.");
+  std::thread::id thread_id_;
+
+  absl::Notification running_notification_;
+  absl::Notification shutdown_notification_;
+
+  absl::Mutex scheduled_actions_mutex_;
+  std::queue<quiche::SingleUseCallback<void()>> scheduled_actions_
+      ABSL_GUARDED_BY(scheduled_actions_mutex_);
+
+  QuicVersionManager version_manager_{ParsedQuicVersionVector(
+      kSupportedQuicVersions.begin(), kSupportedQuicVersions.end())};
+  DeterministicConnectionIdGenerator connection_id_generator_{
+      kQuicDefaultConnectionIdLength};
+
+  std::unique_ptr<QuicEventLoop> event_loop_;
+  std::unique_ptr<Dispatcher> dispatcher_;
+  OwnedSocketFd socket_fd_;
+  std::unique_ptr<QuicServerIoHarness> io_harness_;
+
+  QuicheSocketAddress bound_address_;
+};
+
+BasicQuicServer::BasicQuicServer(
+    QuicheSocketAddress socket_address,
+    std::unique_ptr<ProofSource> absl_nonnull proof_source)
+    : socket_address_(std::move(socket_address)),
+      crypto_config_(std::make_unique<QuicCryptoServerConfig>(
+          QuicCryptoServerConfig::TESTING, QuicRandom::GetInstance(),
+          std::move(proof_source), KeyExchangeSource::Default())) {}
+
+BasicQuicServer::~BasicQuicServer() {
+  if (server_thread_) {
+    QUICHE_LOG(WARNING) << "BasicQuicServer not stopped before destruction.";
+    QUICHE_CHECK_OK(Stop());
+  }
+}
+
+absl::Status BasicQuicServer::Start(
+    std::unique_ptr<Handler> absl_nonnull handler) {
+  QUICHE_CHECK(!server_thread_) << "Server already running.";
+
+  server_thread_ = std::make_unique<ServerThread>(
+      socket_address_, &config_, crypto_config_.get(), std::move(handler));
+  server_thread_->Start();
+
+  absl::Status status = server_thread_->WaitForRunning();
+  if (!status.ok()) {
+    server_thread_->Shutdown();
+    server_thread_.reset();
+    return status;
+  }
+
+  return absl::OkStatus();
+}
+
+absl::Status BasicQuicServer::Stop() {
+  QUICHE_CHECK(server_thread_) << "Server not running.";
+
+  server_thread_->Shutdown();
+  server_thread_.reset();
+
+  return absl::OkStatus();
+}
+
+void BasicQuicServer::Schedule(quiche::SingleUseCallback<void()> callback) {
+  QUICHE_CHECK(server_thread_) << "Server not running.";
+  server_thread_->Schedule(std::move(callback));
+}
+
+void BasicQuicServer::ScheduleAndWaitForCompletion(
+    quiche::SingleUseCallback<void()> callback) {
+  QUICHE_CHECK(server_thread_) << "Server not running.";
+  server_thread_->ScheduleAndWaitForCompletion(std::move(callback));
+}
+
+absl::StatusOr<QuicheSocketAddress> BasicQuicServer::bound_address() {
+  QUICHE_CHECK(server_thread_) << "Server not running.";
+
+  absl::StatusOr<QuicheSocketAddress> result;
+  const ServerThread* server_thread = server_thread_.get();
+  ScheduleAndWaitForCompletion(
+      [&result, server_thread]() { result = server_thread->bound_address(); });
+  return result;
+}
+
+absl::StatusOr<int> BasicQuicServer::SendStreamData(
+    QuicConnectionId server_connection_id, QuicStreamId stream_id,
+    absl::Span<const std::byte> data, bool fin) {
+  auto mem_slice = QuicheMemSlice::Copy(absl::string_view(
+      reinterpret_cast<const char*>(data.data()), data.size()));
+  return SendStreamData(server_connection_id, stream_id,
+                        absl::MakeSpan(&mem_slice, 1), fin);
+}
+
+absl::StatusOr<int> BasicQuicServer::SendStreamData(
+    QuicConnectionId server_connection_id, QuicStreamId stream_id,
+    absl::Span<quiche::QuicheMemSlice> data, bool fin) {
+  QUICHE_CHECK(server_thread_) << "Server not running.";
+
+  ServerThread* server_thread = server_thread_.get();
+  absl::StatusOr<int> result;
+  ScheduleAndWaitForCompletion(
+      [&result, server_thread, server_connection_id, stream_id, data, fin]() {
+        result = server_thread->SendStreamData(server_connection_id, stream_id,
+                                               data, fin);
+      });
+  return result;
+}
+
+absl::Status BasicQuicServer::SendDatagram(
+    QuicConnectionId server_connection_id, absl::Span<const std::byte> data) {
+  auto mem_slice = QuicheMemSlice::Copy(absl::string_view(
+      reinterpret_cast<const char*>(data.data()), data.size()));
+  return SendDatagram(server_connection_id, absl::MakeSpan(&mem_slice, 1));
+}
+
+absl::Status BasicQuicServer::SendDatagram(
+    QuicConnectionId server_connection_id,
+    absl::Span<quiche::QuicheMemSlice> data) {
+  QUICHE_CHECK(server_thread_) << "Server not running.";
+
+  ServerThread* server_thread = server_thread_.get();
+  absl::Status result;
+  ScheduleAndWaitForCompletion(
+      [&result, server_thread, server_connection_id, data]() {
+        result = server_thread->SendDatagram(server_connection_id, data);
+      });
+  return result;
+}
+
+}  // namespace quic::test
diff --git a/quiche/quic/qbone/test_tools/basic_quic_server.h b/quiche/quic/qbone/test_tools/basic_quic_server.h
new file mode 100644
index 0000000..91b190d
--- /dev/null
+++ b/quiche/quic/qbone/test_tools/basic_quic_server.h
@@ -0,0 +1,124 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#ifndef QUICHE_QUIC_QBONE_TEST_TOOLS_BASIC_QUIC_SERVER_H_
+#define QUICHE_QUIC_QBONE_TEST_TOOLS_BASIC_QUIC_SERVER_H_
+
+#include <cstddef>
+#include <memory>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/strings/string_view.h"
+#include "absl/types/span.h"
+#include "quiche/quic/core/crypto/proof_source.h"
+#include "quiche/quic/core/quic_connection.h"
+#include "quiche/quic/core/quic_connection_id.h"
+#include "quiche/quic/core/quic_dispatcher.h"
+#include "quiche/quic/core/quic_session.h"
+#include "quiche/quic/core/quic_stream_sequencer.h"
+#include "quiche/quic/core/quic_types.h"
+#include "quiche/quic/core/tls_server_handshaker.h"
+#include "quiche/common/quiche_callbacks.h"
+#include "quiche/common/quiche_mem_slice.h"
+#include "quiche/common/quiche_socket_address.h"
+
+namespace quic::test {
+
+// Lightweight QUIC (not necessarily HTTP/3) server for tests and experiments,
+// especially for simulating QBONE Terminator functionality. Receives traffic
+// over a socket on the given socket address, which may be a loopback interface
+// for same-machine (or same-process) testing. Runs on its own dedicated server
+// thread for ease of running same-process as a client.
+class BasicQuicServer final {
+ public:
+  // Interface for handling server events. All calls will be made on the server
+  // thread.
+  class Handler {
+   public:
+    virtual ~Handler() = default;
+
+    // Called on construction of a new QuicSession. Returns the QuicCryptoStream
+    // to use for the session.
+    virtual std::unique_ptr<QuicCryptoStream> OnNewSession(
+        QuicSession* session, const QuicCryptoServerConfig* crypto_config) {
+      return std::make_unique<TlsServerHandshaker>(session, crypto_config);
+    }
+
+    virtual void OnSessionEnd(QuicConnectionId server_connection_id) {}
+
+    // See QuicSession::SelectAlpn.
+    virtual std::vector<absl::string_view>::const_iterator SelectAlpn(
+        QuicConnectionId server_connection_id,
+        const std::vector<absl::string_view>& alpns) const {
+      return alpns.cbegin();
+    }
+
+    // Called on initiation of a new stream from the peer. Returns true iff the
+    // stream should be accepted and created.
+    virtual bool OnNewStream(QuicConnectionId server_connection_id,
+                             QuicStreamId stream_id) {
+      return true;
+    }
+
+    // Called when new data is available on a stream. Returns the number of
+    // bytes that were successfully processed and that should be consumed from
+    // stream buffers.
+    virtual int OnStreamDataAvailable(
+        QuicConnectionId server_connection_id, QuicStreamId stream_id,
+        const QuicStreamSequencer& data_sequencer) {
+      return data_sequencer.ReadableBytes();
+    }
+
+    virtual void OnDatagramReceived(QuicConnectionId server_connection_id,
+                                    absl::Span<const std::byte> data) {}
+  };
+
+  BasicQuicServer(quiche::QuicheSocketAddress socket_address,
+                  std::unique_ptr<ProofSource> absl_nonnull proof_source);
+  ~BasicQuicServer();
+
+  absl::Status Start(std::unique_ptr<Handler> absl_nonnull handler);
+  absl::Status Stop();
+
+  // The following interactions should be safe from any thread, and will
+  // marshall to the server thread as necessary.
+
+  // Not guaranteed to run if the server is stopped.
+  void Schedule(quiche::SingleUseCallback<void()> callback);
+  // Not guaranteed to ever return if the server is stopped.
+  void ScheduleAndWaitForCompletion(quiche::SingleUseCallback<void()> callback);
+
+  absl::StatusOr<quiche::QuicheSocketAddress> bound_address();
+
+  // Returns the number of bytes consumed into the stream (written or buffered).
+  absl::StatusOr<int> SendStreamData(QuicConnectionId server_connection_id,
+                                     QuicStreamId stream_id,
+                                     absl::Span<const std::byte> data,
+                                     bool fin = false);
+  absl::StatusOr<int> SendStreamData(QuicConnectionId server_connection_id,
+                                     QuicStreamId stream_id,
+                                     absl::Span<quiche::QuicheMemSlice> data,
+                                     bool fin = false);
+
+  absl::Status SendDatagram(QuicConnectionId server_connection_id,
+                            absl::Span<const std::byte> data);
+  absl::Status SendDatagram(QuicConnectionId server_connection_id,
+                            absl::Span<quiche::QuicheMemSlice> data);
+
+ private:
+  class ServerThread;
+
+  const quiche::QuicheSocketAddress socket_address_;
+
+  const QuicConfig config_;
+  const std::unique_ptr<QuicCryptoServerConfig> crypto_config_;
+  std::unique_ptr<ServerThread> server_thread_;
+};
+
+}  // namespace quic::test
+
+#endif  // QUICHE_QUIC_QBONE_TEST_TOOLS_BASIC_QUIC_SERVER_H_