Basic dispatcher prototype.

Tests will be followed later.

PiperOrigin-RevId: 772497181
diff --git a/centipede/BUILD b/centipede/BUILD
index 9c43a21..6366036 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -982,6 +982,20 @@
     # This has no dependency and only uses string, pair and vector.
 )
 
+cc_library(
+    name = "dispatcher",
+    srcs = ["dispatcher.cc"],
+    hdrs = ["dispatcher.h"],
+    deps = [
+        ":execution_metadata",
+        ":runner_request",
+        ":runner_result",
+        ":shared_memory_blob_sequence",
+        "@abseil-cpp//absl/base:nullability",
+        "@com_google_fuzztest//common:defs",
+    ],
+)
+
 # The runner library is special:
 #   * It must not be instrumented with asan, sancov, etc.
 #   * It must not have heavy dependencies, and ideally not at all.
diff --git a/centipede/dispatcher.cc b/centipede/dispatcher.cc
new file mode 100644
index 0000000..45e8c6a
--- /dev/null
+++ b/centipede/dispatcher.cc
@@ -0,0 +1,594 @@
+// Copyright 2025 The Centipede Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "./centipede/dispatcher.h"
+
+#include <fcntl.h>
+#include <unistd.h>
+
+#include <atomic>
+#include <cerrno>
+#include <cstdint>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "absl/base/nullability.h"
+#include "./centipede/execution_metadata.h"
+#include "./centipede/runner_request.h"
+#include "./centipede/runner_result.h"
+#include "./centipede/shared_memory_blob_sequence.h"
+#include "./common/defs.h"
+
+namespace fuzztest::internal {
+
+namespace {
+
+// Logging needs to be signal safe.
+
+struct LogErrNo {};
+struct LogLnSync {};
+
+void DispatcherLog() {}
+
+template <typename T, typename... Rest>
+void DispatcherLog(const T& first, const Rest&... rest) {
+  if constexpr (std::is_same_v<LogErrNo, T>) {
+    auto saved_errno = errno;
+    char err_buf[80];
+    if (strerror_r(saved_errno, err_buf, sizeof(err_buf)) != 0) {
+      constexpr std::string_view kFallbackMsg = "[strerror_r failed]";
+      static_assert(kFallbackMsg.size() < sizeof(err_buf));
+      std::memcpy(err_buf, kFallbackMsg.data(), kFallbackMsg.size());
+      err_buf[kFallbackMsg.size()] = 0;
+    }
+    DispatcherLog(err_buf);
+  } else if constexpr (std::is_same_v<LogLnSync, T>) {
+    write(STDERR_FILENO, "\n", 1);
+    fsync(STDERR_FILENO);
+  } else {
+    std::string_view sv = first;
+    while (!sv.empty()) {
+      const int r = write(STDERR_FILENO, sv.data(), sv.size());
+      if (r <= 0) break;
+      sv = sv.substr(r);
+    }
+  }
+  DispatcherLog(rest...);
+}
+
+inline void DispatcherCheck(bool condition, std::string_view error) {
+  if (!condition) {
+    DispatcherLog(error, LogLnSync{});
+    std::_Exit(1);
+  }
+}
+
+const char* GetDispatcherFlags() {
+  static auto dispatcher_flags = []() -> const char* {
+    // TODO(xinhaoyuan): Rename the env name to FUZZTEST_DISPATCHER_FLAGS.
+    const char* env_flags = std::getenv("CENTIPEDE_RUNNER_FLAGS");
+    if (env_flags == nullptr) return nullptr;
+    const char* result = strdup(env_flags);
+    DispatcherCheck(result != nullptr, "Cannot copy the dispatcher flags");
+    return result;
+  }();
+  return dispatcher_flags;
+}
+
+std::optional<std::string_view> GetDispatcherFlag(
+    const char* absl_nonnull flag_header) {
+  const char* dispatcher_flags = GetDispatcherFlags();
+  if (dispatcher_flags == nullptr) return std::nullopt;
+  // Extract "value" from ":flag=value:"
+  const char* beg = std::strstr(dispatcher_flags, flag_header);
+  if (!beg) return std::nullopt;
+  const char* value_beg = beg + std::strlen(flag_header);
+  const char* value_end = std::strstr(value_beg, ":");
+  if (!value_end) return std::nullopt;
+  return std::string_view{value_beg,
+                          static_cast<size_t>(value_end - value_beg)};
+}
+
+bool HasDispatcherSwitchFlag(const char* absl_nonnull switch_flag) {
+  const char* dispatcher_flags = GetDispatcherFlags();
+  if (dispatcher_flags == nullptr) return false;
+  return std::strstr(dispatcher_flags, switch_flag) != nullptr;
+}
+
+enum class DispatcherAction {
+  kGetBinaryId,
+  kListTests,
+  kTestGetSeeds,
+  kTestMutate,
+  kTestExecute,
+};
+
+constexpr char kDispatcherBinaryIdOutputFlagHeader[] = ":binary_id_output=";
+constexpr char kDispatcherTestNameFlagHeader[] = ":test=";
+constexpr char kDispatcherTestListingPrefixFlagHeader[] =
+    ":test_listing_prefix=";
+constexpr char kDispatcherTestGetSeedsOutputDirFlagHeader[] =
+    ":arg1=";  // TODO: Use better flag names when standardizing the protocol.
+constexpr char kDispatcherFailureDescriptionPathFlagHeader[] =
+    ":failure_description_path=";
+constexpr char kDispatcherFailureSignaturePathFlagHeader[] =
+    ":failure_signature_path=";
+constexpr char kDispatcherInputsBlobSequencePathFlagHeader[] =
+    ":arg1=";  // TODO: Use better flag names when standardizing the protocol.
+constexpr char kDispatcherOutputsBlobSequencePathFlagHeader[] =
+    ":arg2=";  // TODO: Use better flag names when standardizing the protocol.
+
+BlobSequence* GetInputsBlobSequence() {
+  static auto result = []() -> BlobSequence* {
+    if (std::strstr(GetDispatcherFlags(), ":shmem:") == nullptr) {
+      return nullptr;
+    }
+    auto input_path =
+        GetDispatcherFlag(kDispatcherInputsBlobSequencePathFlagHeader);
+    DispatcherCheck(input_path.has_value(), "inputs blob sequence is missing");
+    return new SharedMemoryBlobSequence(std::string(*input_path).c_str());
+  }();
+  return result;
+}
+
+BlobSequence* GetOutputsBlobSequence() {
+  static auto result = []() -> BlobSequence* {
+    if (std::strstr(GetDispatcherFlags(), ":shmem:") == nullptr) {
+      return nullptr;
+    }
+    auto output_path =
+        GetDispatcherFlag(kDispatcherOutputsBlobSequencePathFlagHeader);
+    DispatcherCheck(output_path.has_value(),
+                    "outputs blob sequence is missing");
+    return new SharedMemoryBlobSequence(std::string(*output_path).c_str());
+  }();
+  return result;
+}
+
+DispatcherAction GetDispatcherAction() {
+  static DispatcherAction dispatcher_action = [] {
+    if (HasDispatcherSwitchFlag(":dump_binary_id:")) {
+      return DispatcherAction::kGetBinaryId;
+    }
+    if (HasDispatcherSwitchFlag(":list_tests:")) {
+      return DispatcherAction::kListTests;
+    }
+    if (HasDispatcherSwitchFlag(":dump_seed_inputs:")) {
+      return DispatcherAction::kTestGetSeeds;
+    }
+    auto* inputs_blobseq = GetInputsBlobSequence();
+    DispatcherCheck(inputs_blobseq != nullptr,
+                    "input blob sequence is not found");
+    auto request_type_blob = inputs_blobseq->Read();
+    if (IsMutationRequest(request_type_blob)) {
+      inputs_blobseq->Reset();
+      return DispatcherAction::kTestMutate;
+    }
+    if (IsExecutionRequest(request_type_blob)) {
+      inputs_blobseq->Reset();
+      return DispatcherAction::kTestExecute;
+    }
+    DispatcherCheck(false, "unknown dispatcher action from the flags");
+    // should not reach here.
+    std::abort();
+  }();
+  return dispatcher_action;
+}
+
+template <typename... C>
+void TrySetFileContents(const char* absl_nonnull path, C... contents) {
+  // Needs to be signal-safe.
+  int f = open(path, O_CREAT | O_TRUNC | O_WRONLY, /*mode=*/0660);
+  if (f == -1) {
+    DispatcherLog("cannot open path ", path, ": ", LogErrNo{}, LogLnSync{});
+    return;
+  }
+  ([&] {
+    std::string_view sv = contents;
+    while (!sv.empty()) {
+      const int r = write(f, sv.data(), sv.size());
+      if (r < 0) {
+        DispatcherLog("write() failed on ", path, ": ", LogErrNo{},
+                      LogLnSync{});
+        return false;
+      }
+      if (r == 0) {
+        DispatcherLog("write() on ", path,
+                      " returns 0 unexpectedly. Stopping writing the file.");
+        return false;
+      }
+      sv = sv.substr(r);
+    }
+    return true;
+  }() &&
+   ...);  // NOLINT - stop fighting with auto-fomatting.
+  if (fsync(f) != 0) {
+    DispatcherLog("fsync() failed on ", path, ": ", LogErrNo{}, LogLnSync{});
+  }
+  if (close(f) != 0) {
+    DispatcherLog("close() failed on ", path, ": ", LogErrNo{}, LogLnSync{});
+  }
+}
+
+static std::atomic<bool> in_test_callback = false;
+
+class TestCallbackGuard {
+ public:
+  TestCallbackGuard() {
+    DispatcherCheck(!in_test_callback.exchange(true),
+                    "test callback is already activated");
+  }
+
+  ~TestCallbackGuard() { in_test_callback = false; }
+};
+
+void DispatcherDoGetBinaryId(const FuzzTestDispatcherCallbacks& callbacks) {
+  const auto binary_id_output_path =
+      GetDispatcherFlag(kDispatcherBinaryIdOutputFlagHeader);
+  DispatcherCheck(binary_id_output_path.has_value(),
+                  "binary ID output path is not set");
+  std::string binary_id;
+  {
+    TestCallbackGuard guard;
+    binary_id = callbacks.get_binary_id ? callbacks.get_binary_id() : "";
+  }
+  TrySetFileContents(std::string{*binary_id_output_path}.c_str(), binary_id);
+}
+
+void DispatcherDoListTests(const FuzzTestDispatcherCallbacks& callbacks) {
+  DispatcherCheck(callbacks.list_tests != nullptr,
+                  "list_tests callback must be set");
+  TestCallbackGuard guard;
+  callbacks.list_tests();
+}
+
+void DispatcherDoGetSeeds(const FuzzTestDispatcherCallbacks& callbacks) {
+  if (callbacks.get_seeds == nullptr) {
+    return;
+  }
+  TestCallbackGuard guard;
+  callbacks.get_seeds();
+}
+
+int DispatcherDoMutate(const FuzzTestDispatcherCallbacks& callbacks) {
+  auto* inputs_blobseq = GetInputsBlobSequence();
+  auto* outputs_blobseq = GetOutputsBlobSequence();
+  DispatcherCheck(inputs_blobseq != nullptr && outputs_blobseq != nullptr,
+                  "inputs/outputs blob sequences must be specified");
+
+  bool has_mutate = callbacks.mutate != nullptr;
+  if (!MutationResult::WriteHasCustomMutator(has_mutate, *outputs_blobseq)) {
+    std::fprintf(stderr, "Failed to write custom mutator indicator!\n");
+    return EXIT_FAILURE;
+  }
+  if (!has_mutate) {
+    return EXIT_SUCCESS;
+  }
+
+  // Read max_num_mutants.
+  size_t num_mutants = 0;
+  size_t num_inputs = 0;
+  if (!IsMutationRequest(inputs_blobseq->Read())) {
+    std::fprintf(stderr, "Not mutation request!\n");
+    return EXIT_FAILURE;
+  }
+  if (!IsNumMutants(inputs_blobseq->Read(), num_mutants)) {
+    std::fprintf(stderr, "No num mutants\n");
+    return EXIT_FAILURE;
+  }
+  if (!IsNumInputs(inputs_blobseq->Read(), num_inputs)) {
+    std::fprintf(stderr, "No num inputs\n");
+    return EXIT_FAILURE;
+  }
+
+  struct OwningMutateInput {
+    ByteArray data;
+    ExecutionMetadata metadata;
+  };
+  // Note: unclear if we can continue using std::vector (or other STL)
+  // in the runner. But for now use std::vector.
+  //
+  // Collect the inputs into a vector. We copy them instead of using pointers
+  // into shared memory so that the user code doesn't touch the shared memory.
+  std::vector<OwningMutateInput> owning_inputs;
+  owning_inputs.reserve(num_inputs);
+  std::vector<FuzzTestDispatcherInputForMutate> inputs;
+  inputs.reserve(num_inputs);
+  for (size_t i = 0; i < num_inputs; ++i) {
+    // If inputs_blobseq have overflown in the engine, we still want to
+    // handle the first few inputs.
+    ExecutionMetadata metadata;
+    if (!IsExecutionMetadata(inputs_blobseq->Read(), metadata)) {
+      break;
+    }
+    auto blob = inputs_blobseq->Read();
+    if (!IsDataInput(blob)) break;
+    owning_inputs.push_back(
+        OwningMutateInput{/*data=*/ByteArray{blob.data, blob.data + blob.size},
+                          /*metadata=*/std::move(metadata)});
+    inputs.push_back(FuzzTestDispatcherInputForMutate{
+        /*input=*/owning_inputs.back().data.data(),
+        /*input_size=*/owning_inputs.back().data.size(),
+        /*metadata=*/owning_inputs.back().metadata.cmp_data.data(),
+        /*metadata_size=*/owning_inputs.back().metadata.cmp_data.size()});
+  }
+
+  {
+    TestCallbackGuard guard;
+    fprintf(stderr, "calling custom mutator\n");
+    callbacks.mutate(inputs.data(), inputs.size(), num_mutants,
+                     /*shrink=*/0);
+  }
+  return EXIT_SUCCESS;
+}
+
+int DispatcherDoExecute(const FuzzTestDispatcherCallbacks& callbacks) {
+  DispatcherCheck(callbacks.execute != nullptr, "execute callback must be set");
+  auto* inputs_blobseq = GetInputsBlobSequence();
+  auto* outputs_blobseq = GetOutputsBlobSequence();
+  DispatcherCheck(inputs_blobseq != nullptr && outputs_blobseq != nullptr,
+                  "inputs/ouptuts blob sequence must exist");
+
+  size_t num_inputs = 0;
+  DispatcherCheck(IsExecutionRequest(inputs_blobseq->Read()),
+                  "not an execution request");
+  DispatcherCheck(IsNumInputs(inputs_blobseq->Read(), num_inputs),
+                  "failed to read num_inputs");
+
+  for (size_t i = 0; i < num_inputs; i++) {
+    auto blob = inputs_blobseq->Read();
+    if (!blob.IsValid()) return EXIT_SUCCESS;  // no more blobs to read.
+    if (!IsDataInput(blob)) return EXIT_FAILURE;
+
+    // Copy from blob to data so that to not pass the shared memory further.
+    ByteArray data(blob.data, blob.data + blob.size);
+
+    if (!BatchResult::WriteInputBegin(*outputs_blobseq)) {
+      // TODO: This is to follow the previous behavior, but should we abort
+      // here?
+      break;
+    }
+    {
+      TestCallbackGuard guard;
+      callbacks.execute(data.data(), data.size());
+    }
+    if (!BatchResult::WriteInputEnd(*outputs_blobseq)) {
+      // TODO: This is to follow the previous behavior, but should we abort
+      // here?
+      break;
+    }
+  }
+
+  return EXIT_SUCCESS;
+}
+
+void DispatcherEmitFailure(const char* absl_nonnull prefix,
+                           const char* absl_nonnull description,
+                           const char* signature, size_t signature_size) {
+  bool success = false;
+  [[maybe_unused]] static bool write_once = [=, &success] {
+    if (const auto failure_description_path =
+            GetDispatcherFlag(kDispatcherFailureDescriptionPathFlagHeader);
+        failure_description_path.has_value()) {
+      TrySetFileContents(std::string{*failure_description_path}.c_str(), prefix,
+                         description);
+    }
+    if (const auto failure_signature_path =
+            GetDispatcherFlag(kDispatcherFailureSignaturePathFlagHeader);
+        failure_signature_path.has_value()) {
+      TrySetFileContents(std::string{*failure_signature_path}.c_str(),
+                         std::string_view{signature, signature_size});
+    }
+    success = true;
+    return true;
+  }();
+  if (!success) {
+    DispatcherLog("Failed to emit failure ", prefix, description, LogLnSync{});
+  }
+}
+
+}  // namespace
+
+}  // namespace fuzztest::internal
+
+using fuzztest::internal::BatchResult;
+using fuzztest::internal::DispatcherAction;
+using fuzztest::internal::DispatcherCheck;
+using fuzztest::internal::DispatcherDoExecute;
+using fuzztest::internal::DispatcherDoGetBinaryId;
+using fuzztest::internal::DispatcherDoGetSeeds;
+using fuzztest::internal::DispatcherDoListTests;
+using fuzztest::internal::DispatcherDoMutate;
+using fuzztest::internal::DispatcherEmitFailure;
+using fuzztest::internal::GetDispatcherAction;
+using fuzztest::internal::GetDispatcherFlag;
+using fuzztest::internal::GetDispatcherFlags;
+using fuzztest::internal::GetOutputsBlobSequence;
+using fuzztest::internal::HasDispatcherSwitchFlag;
+using fuzztest::internal::in_test_callback;
+using fuzztest::internal::kDispatcherTestGetSeedsOutputDirFlagHeader;
+using fuzztest::internal::kDispatcherTestListingPrefixFlagHeader;
+using fuzztest::internal::kDispatcherTestNameFlagHeader;
+using fuzztest::internal::MutationResult;
+
+int FuzzTestDispatcherIsEnabled() {
+  const char* flags = GetDispatcherFlags();
+  if (flags == nullptr) return 0;
+  fprintf(stderr, "Dispatcher is enabled with flags: %s\n", flags);
+  return 1;
+}
+
+const char* FuzzTestDispatcherGetTestName() {
+  static auto test_name = []() -> const char* {
+    const auto test_name = GetDispatcherFlag(kDispatcherTestNameFlagHeader);
+    if (!test_name.has_value()) return nullptr;
+    return strndup(test_name->data(), test_name->size());
+  }();
+  return test_name;
+}
+
+int FuzzTestDispatcherRun(const FuzzTestDispatcherCallbacks* callbacks) {
+  DispatcherCheck(callbacks != nullptr, "callbacks must be set");
+  if (HasDispatcherSwitchFlag(":dump_configuration:")) {
+    return 0;
+  }
+  switch (GetDispatcherAction()) {
+    case DispatcherAction::kGetBinaryId:
+      DispatcherDoGetBinaryId(*callbacks);
+      break;
+    case DispatcherAction::kListTests:
+      DispatcherDoListTests(*callbacks);
+      break;
+    case DispatcherAction::kTestGetSeeds:
+      DispatcherDoGetSeeds(*callbacks);
+      break;
+    case DispatcherAction::kTestMutate:
+      DispatcherDoMutate(*callbacks);
+      break;
+    case DispatcherAction::kTestExecute:
+      DispatcherDoExecute(*callbacks);
+      break;
+    default:
+      DispatcherCheck(false, "unknown dispatcher action to take");
+  }
+  return 0;
+}
+
+void FuzzTestDispatcherEmitTestName(const char* name) {
+  DispatcherCheck(
+      GetDispatcherAction() == DispatcherAction::kListTests && in_test_callback,
+      "must be called inside test callback for listing tests");
+  static auto test_listing_prefix =
+      GetDispatcherFlag(kDispatcherTestListingPrefixFlagHeader);
+  DispatcherCheck(test_listing_prefix.has_value(),
+                  "test listing path prefix must be set");
+  DispatcherCheck(name != nullptr, "test name must be set");
+  auto test_output_path = std::string{*test_listing_prefix};
+  test_output_path += name;
+  FILE* f = std::fopen(test_output_path.c_str(), "w");
+  if (f == nullptr) {
+    std::perror("FAILURE: fopen()");
+  }
+  std::fclose(f);
+}
+
+void FuzzTestDispatcherEmitSeed(const void* data, size_t size) {
+  DispatcherCheck(GetDispatcherAction() == DispatcherAction::kTestGetSeeds &&
+                      in_test_callback,
+                  "must be called inside test callback for getting seeds");
+  DispatcherCheck(size > 0 && data != nullptr,
+                  "seed must be non-empty with a valid pointer");
+  static size_t seed_index = 0;
+  static const char* output_dir = [] {
+    const auto flag_value =
+        GetDispatcherFlag(kDispatcherTestGetSeedsOutputDirFlagHeader);
+    DispatcherCheck(flag_value.has_value(),
+                    "seeds output path must be specified");
+    const char* result = strndup(flag_value->data(), flag_value->size());
+    DispatcherCheck(result != nullptr, "failed to copy the seeds output path");
+    return result;
+  }();
+  // Cap seed index within 9 digits. If this was triggered, the dumping would
+  // take forever..
+  if (seed_index >= 1000000000) return;
+  char seed_path_buf[PATH_MAX];
+  const size_t num_path_chars =
+      snprintf(seed_path_buf, PATH_MAX, "%s/%09lu", output_dir, seed_index);
+  DispatcherCheck(num_path_chars < PATH_MAX, "seed path reaches PATH_MAX");
+  FILE* output_file = fopen(seed_path_buf, "w");
+  const size_t num_bytes_written = fwrite(data, 1, size, output_file);
+  DispatcherCheck(num_bytes_written == size,
+                  "wrong number of bytes written for seed");
+  fclose(output_file);
+  ++seed_index;
+}
+
+void FuzzTestDispatcherEmitMutant(const void* data, size_t size) {
+  DispatcherCheck(GetDispatcherAction() == DispatcherAction::kTestMutate &&
+                      in_test_callback,
+                  "must be called inside test callback for mutating");
+  DispatcherCheck(size > 0 && data != nullptr,
+                  "mutant must be non-empty with a valid pointer");
+  auto* output = GetOutputsBlobSequence();
+  DispatcherCheck(output != nullptr, "outputs blob sequence must exist");
+  DispatcherCheck(MutationResult::WriteMutant(
+                      {static_cast<const uint8_t*>(data), size}, *output),
+                  "failed to write mutant");
+}
+
+void FuzzTestDispatcherEmitFeedbackAs32BitFeatures(const uint32_t* features,
+                                                   size_t num_features) {
+  DispatcherCheck(GetDispatcherAction() == DispatcherAction::kTestExecute &&
+                      in_test_callback,
+                  "must be called inside test callback of executing");
+  DispatcherCheck(num_features > 0 && features != nullptr,
+                  "feature array must be non-empty with a valid pointer");
+  auto* output = GetOutputsBlobSequence();
+  DispatcherCheck(output != nullptr, "outputs blob sequence must exist");
+  DispatcherCheck(BatchResult::WriteDispatcher32BitFeatures(
+                      features, num_features, *output),
+                  "failed to write feedback");
+}
+
+void FuzzTestDispatcherEmitExecutionMetadata(const void* metadata,
+                                             size_t size) {
+  DispatcherCheck(GetDispatcherAction() == DispatcherAction::kTestExecute &&
+                      in_test_callback,
+                  "must be called inside test callback of executing");
+  DispatcherCheck(size > 0 && metadata != nullptr,
+                  "metadata must be non-empty with a valid pointer");
+  auto* output = GetOutputsBlobSequence();
+  DispatcherCheck(output != nullptr, "outputs blob sequence must exist");
+  DispatcherCheck(BatchResult::WriteMetadata(
+                      {static_cast<const uint8_t*>(metadata), size}, *output),
+                  "failed to write metadata");
+}
+
+void FuzzTestDispatcherEmitInputFailure(const char* description,
+                                        const void* signature,
+                                        size_t signature_size) {
+  DispatcherCheck(GetDispatcherAction() == DispatcherAction::kTestExecute &&
+                      in_test_callback,
+                  "must be called inside test callback for executing");
+  DispatcherCheck((signature == nullptr) == (signature_size == 0),
+                  "violated invariant: signature should be nullptr if and only "
+                  "if signature_size is 0");
+  DispatcherEmitFailure(
+      "INPUT FAILURE: ", description != nullptr ? description : "",
+      reinterpret_cast<const char*>(signature), signature_size);
+}
+
+void FuzzTestDispatcherEmitIgnoredFailure(const char* description) {
+  DispatcherEmitFailure(
+      "IGNORED FAILURE: ", description != nullptr ? description : "",
+      /*signature=*/nullptr, /*signature_size=*/0);
+}
+
+void FuzzTestDispatcherEmitSetupFailure(const char* description) {
+  DispatcherEmitFailure(
+      "SETUP FAILURE: ", description != nullptr ? description : "",
+      /*signature=*/nullptr, /*signature_size=*/0);
+}
+
+void FuzzTestDispatcherEmitSkippedTestFailure(const char* description) {
+  DispatcherEmitFailure(
+      "SKIPPED TEST: ", description != nullptr ? description : "",
+      /*signature=*/nullptr, /*signature_size=*/0);
+}
diff --git a/centipede/dispatcher.h b/centipede/dispatcher.h
new file mode 100644
index 0000000..63f4f08
--- /dev/null
+++ b/centipede/dispatcher.h
@@ -0,0 +1,141 @@
+// Copyright 2025 The Centipede Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef THIRD_PARTY_CENTIPEDE_DISPATCHER_H_
+#define THIRD_PARTY_CENTIPEDE_DISPATCHER_H_
+
+// Dispatcher interface.
+//
+// This header needs to be C compatible.
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Inputs to perform mutations.
+struct FuzzTestDispatcherInputForMutate {
+  const void* input;
+  size_t input_size;
+  const void* metadata;
+  size_t metadata_size;
+};
+
+// Callbacks to be provided by the fuzz testing framework to
+// `FuzzTestDispatcherRun`.
+struct FuzzTestDispatcherCallbacks {
+  // Optional callback to return an ID for the current binary. If not
+  // implemented, the controller will generate a default ID based on the binary
+  // path.
+  const char* (*get_binary_id)();
+  // Callback to emit the list of available tests in the binary using
+  // `FuzzTestDispatcherEmitTestName`.
+  void (*list_tests)();
+  // Callback to emit the seed inputs for a test using
+  // `FuzzTestDispatcherEmitSeed`.
+  void (*get_seeds)();
+  // Optional callback to emit at most `num_mutants` from `inputs` with
+  // `num_inputs` entries using `FuzzTestDispatcherEmitMutant`. `shrink` != 0
+  // means to generate smaller mutants than the inputs used for mutation. If not
+  // implemented, the controller will perform basic string-based mutations.
+  //
+  // TODO: xinhaoyuan - Reconsider mutation interface design instead of
+  // following the existing Centipede/runner protocol.
+  void (*mutate)(const struct FuzzTestDispatcherInputForMutate* inputs,
+                 size_t num_inputs, size_t num_mutants, int shrink);
+  // Callback to execute `input` with `size` bytes. The callback should emit
+  // coverage feedback using `FuzzTestDispatcherEmitFeedback*` functions, and
+  // any metadata for further mutation using
+  // `FuzzTestDispatEmitExecutionMetadata`. In case the input caused a failure,
+  // the callback should emit the failure using
+  // `FuzzTestDispatcherEmitInputFailure`.
+  void (*execute)(const void* input, size_t size);
+};
+
+// Functions provided by the FuzzTest engine.
+
+// Returns 0 if the dispatcher mode is not enabled in the current process; 1 if
+// the dispatcher mode is enabled; other values for unexpected errors.
+int FuzzTestDispatcherIsEnabled();
+
+// All functions below should be called only after `FuzzTestDispatcherIsEnabled`
+// returns 1 in the current process.
+
+// Returns the test name under operation as an unowned, static, and
+// null-terminated string. Returns nullptr if the current process is not
+// operating on a specific test.
+const char* FuzzTestDispatcherGetTestName();
+
+// Give control to the FuzzTest engine to invoke `callbacks`. Returns an exit
+// code for the current process desired by the engine.
+int FuzzTestDispatcherRun(const struct FuzzTestDispatcherCallbacks* callbacks);
+
+// Emits a test name. Must be called from the `list_tests` callback. `name` must
+// be a null-terminated string.
+void FuzzTestDispatcherEmitTestName(const char* name);
+
+// Emits a seed input. Must be called from the `get_seeds` callback. `data` must
+// not be nullptr and `size > 0` must hold.
+void FuzzTestDispatcherEmitSeed(const void* data, size_t size);
+
+// Emits a mutant. Must be called from the `mutate` callback. `data` must not be
+// nullptr and `size > 0` must hold.
+void FuzzTestDispatcherEmitMutant(const void* data, size_t size);
+
+// Emits coverage feedback for the current input as an array of 32-bit features.
+//
+// For each 32-bit feature, the bit [31] is ignored; the 4 bits [30-27]
+// indicate the feature domain for engine prioritization. The remaining 27 bits
+// [26-0] represent the actual 27-bit feature ID in the domain.
+//
+// Must be called from the `execute` callback. `features` must not be nullptr
+// and `num_features > 0` must hold.
+void FuzzTestDispatcherEmitFeedbackAs32BitFeatures(const uint32_t* features,
+                                                   size_t num_features);
+// Emits metadata of the current input as raw bytes. Must be called from
+// the `execute` callback.
+void FuzzTestDispatcherEmitExecutionMetadata(const void* metadata, size_t size);
+
+// Functions for emitting various types of failures. After calling any of these
+// functions, later calls of these functions would have no effect, and the
+// current process should exit after necessary cleanup.
+
+// Emits a failure caused by executing an input. Must be called within the
+// `execute` callback. `description` should be a null-terminated string, or
+// nullptr can be passed for an empty string; `signature` should be nullptr if
+// and only if `signature_size == 0`.
+void FuzzTestDispatcherEmitInputFailure(const char* description,
+                                        const void* signature,
+                                        size_t signature_size);
+
+// Emits a failure that should be ignored (i.e. not affecting the fuzzing
+// workflows). `description` should be a null-terminated string, or nullptr can
+// be passed for an empty string.
+void FuzzTestDispatcherEmitIgnoredFailure(const char* description);
+
+// Emits a failure caused by the test setup. `description` should be a
+// null-terminated string, or nullptr can be passed for an empty string.
+void FuzzTestDispatcherEmitSetupFailure(const char* description);
+
+// Emits a failure due to reasons to skip the entire test. `description` should
+// be a null-terminated string, or nullptr can be passed for an empty string.
+void FuzzTestDispatcherEmitSkippedTestFailure(const char* description);
+
+#ifdef __cplusplus
+}  // extern "C"
+#endif
+
+#endif