Add stop request reason string that can be exported.

Also added tests for :stop, including a concurrency stress test.

PiperOrigin-RevId: 964251184
diff --git a/centipede/BUILD b/centipede/BUILD
index 1259192..d486c85 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -1451,6 +1451,17 @@
 )
 
 cc_test(
+    name = "stop_test",
+    srcs = ["stop_test.cc"],
+    deps = [
+        ":stop",
+        ":thread_pool",
+        "@abseil-cpp//absl/time",
+        "@googletest//:gtest_main",
+    ],
+)
+
+cc_test(
     name = "rusage_stats_test",
     size = "medium",
     timeout = "long",
diff --git a/centipede/centipede.cc b/centipede/centipede.cc
index e2a0ee3..16df2e0 100644
--- a/centipede/centipede.cc
+++ b/centipede/centipede.cc
@@ -467,10 +467,12 @@
     success = ExecuteAndReportCrash(extra_binary, inputs, extra_batch_result) &&
               success;
   }
-  if (stop_condition_.EarlyStopRequested()) return false;
+  if (stop_condition_.StopRequested()) return false;
   if (!success && env_.exit_on_crash) {
     FUZZTEST_LOG(INFO) << "--exit_on_crash is enabled; exiting soon";
-    stop_condition_.RequestEarlyStop(EXIT_FAILURE);
+    stop_condition_.RequestStop(EXIT_FAILURE,
+                                "A crash was found in the test with "
+                                "--exit_on_crash set for the engine");
     return false;
   }
   bool batch_gained_new_coverage = false;
@@ -1017,7 +1019,7 @@
   if (batch_result.IsSkippedTest()) {
     log_execution_failure("Skipped Test: ");
     FUZZTEST_LOG(INFO) << "Requesting early stop due to skipped test.";
-    stop_condition_.RequestEarlyStop(EXIT_SUCCESS);
+    stop_condition_.RequestStop(EXIT_SUCCESS, "The test was skipped");
     return;
   }
 
@@ -1025,13 +1027,13 @@
     log_execution_failure("Test Setup Failure: ");
     FUZZTEST_LOG(INFO)
         << "Requesting early stop due to setup failure in the test.";
-    stop_condition_.RequestEarlyStop(EXIT_FAILURE);
+    stop_condition_.RequestStop(EXIT_FAILURE, "Setup failed in the test");
     return;
   }
 
-  // Skip reporting only if RequestEarlyStop is called - still reporting if time
+  // Skip reporting only if RequestStop is called - still reporting if time
   // limit is reached.
-  if (stop_condition_.EarlyStopRequested()) return;
+  if (stop_condition_.StopRequested()) return;
 
   if (++num_crashes_ > env_.max_num_crash_reports) return;
 
diff --git a/centipede/centipede_callbacks.cc b/centipede/centipede_callbacks.cc
index c615992..1bcb1e3 100644
--- a/centipede/centipede_callbacks.cc
+++ b/centipede/centipede_callbacks.cc
@@ -306,9 +306,11 @@
   // Check the PC table.
   if (binary_info.pc_table.empty()) {
     if (env_.require_pc_table) {
-      FUZZTEST_LOG(ERROR) << "Could not get PC table; exiting (override with "
-                             "--require_pc_table=false)";
-      exit(EXIT_FAILURE);
+      FUZZTEST_LOG(ERROR)
+          << "Could not get PC table; requesting to stop (override with "
+             "--require_pc_table=false)";
+      stop_condition_.RequestStop(EXIT_FAILURE, "Could not get PC table");
+      return;
     }
     FUZZTEST_LOG(WARNING)
         << "Could not get PC table; CF table and debug symbols will "
diff --git a/centipede/centipede_default_callbacks.cc b/centipede/centipede_default_callbacks.cc
index d8b01a3..dc4f055 100644
--- a/centipede/centipede_default_callbacks.cc
+++ b/centipede/centipede_default_callbacks.cc
@@ -124,7 +124,8 @@
       PrintExecutionLog();
       FUZZTEST_LOG(ERROR) << "Test binary failed to mutate inputs at the final "
                              "attempt - exiting.";
-      stop_condition_.RequestEarlyStop(EXIT_FAILURE);
+      stop_condition_.RequestStop(EXIT_FAILURE,
+                                  "Test binary failed to mutate inputs");
       return {};
     }
   }
diff --git a/centipede/centipede_flags.inc b/centipede/centipede_flags.inc
index 0709562..c4a246c 100644
--- a/centipede/centipede_flags.inc
+++ b/centipede/centipede_flags.inc
@@ -162,6 +162,8 @@
                "If set, will ignore reporting timeouts as errors.")
 CENTIPEDE_FLAG(absl::Duration, runner_cleanup_timeout, absl::Seconds(60),
                "Cleanup timeout for runner commands.")
+CENTIPEDE_FLAG(std::string, stop_reason_file, "",
+               "If set, will write any stop reason to the local file path.")
 CENTIPEDE_FLAG(
     absl::Time, stop_at, absl::InfiniteFuture(),
     "Stop fuzzing in all shards (--total_shards) at approximately this "
diff --git a/centipede/centipede_interface.cc b/centipede/centipede_interface.cc
index 4a7e80c..e0793d1 100644
--- a/centipede/centipede_interface.cc
+++ b/centipede/centipede_interface.cc
@@ -88,8 +88,10 @@
     auto blob_reader = DefaultBlobFileReaderFactory();
     absl::Status open_status = blob_reader->Open(arg);
     if (!open_status.ok()) {
-      FUZZTEST_LOG(INFO) << "Failed to open " << arg << ": " << open_status;
-      stop_condition.RequestEarlyStop(EXIT_FAILURE);
+      const std::string stop_reason =
+          absl::StrCat("Failed to open ", arg, ": ", open_status);
+      FUZZTEST_LOG(WARNING) << stop_reason;
+      stop_condition.RequestStop(EXIT_FAILURE, stop_reason);
       return;
     }
     ByteSpan blob;
@@ -145,6 +147,9 @@
     ScopedCentipedeCallbacks scoped_callbacks(callbacks_factory, env,
                                               stop_condition);
     scoped_callbacks.callbacks()->PopulateBinaryInfo(binary_info);
+    if (stop_condition.ShouldStop()) {
+      return binary_info;
+    }
   }
   if (env.save_binary_info) {
     const std::string binary_info_dir = WorkDir{env}.BinaryInfoDirPath();
@@ -460,10 +465,13 @@
   std::string pcs_file_path;
   BinaryInfo binary_info = PopulateBinaryInfoAndSavePCsIfNecessary(
       env, callbacks_factory, pcs_file_path, stop_condition);
+  if (stop_condition.StopRequested()) return;
 
   FUZZTEST_LOG(INFO) << "Test shard index: " << test_shard_index
                      << " Total test shards: " << total_test_shards;
 
+  StopCondition::StopRequest stop_request;
+
   // Step 2: Run the fuzz test.
 
   // Unset stop time. stop_time will be set later.
@@ -529,7 +537,7 @@
 
   absl::Cleanup clean_up_workdir = [is_workdir_specified, &env,
                                     &stop_condition] {
-    if (!is_workdir_specified && !stop_condition.EarlyStopRequested()) {
+    if (!is_workdir_specified && !stop_condition.StopRequested()) {
       FUZZTEST_CHECK_OK(RemotePathDelete(env.workdir, /*recursively=*/true));
     }
   };
@@ -558,11 +566,11 @@
   }
   is_resuming = false;
 
-  if (stop_condition.EarlyStopRequested()) {
-    if (const auto exit_code = stop_condition.ExitCode();
-        exit_code != EXIT_SUCCESS) {
+  if (stop_condition.StopRequested(&stop_request)) {
+    if (stop_request.exit_code != EXIT_SUCCESS) {
       FUZZTEST_LOG(ERROR) << "Early stop requested for test " << env.test_name
-                          << " with failure exit code " << exit_code;
+                          << " with failure exit code "
+                          << stop_request.exit_code;
     } else {
       FUZZTEST_LOG(INFO) << "Skipping test " << env.test_name
                          << " due to early stop requested without failure.";
@@ -590,11 +598,11 @@
         (stats_dir / absl::StrCat("fuzzing_stats_", execution_stamp)).c_str()));
   }
 
-  if (stop_condition.EarlyStopRequested()) {
-    if (const auto exit_code = stop_condition.ExitCode();
-        exit_code != EXIT_SUCCESS) {
+  if (stop_condition.StopRequested(&stop_request)) {
+    if (stop_request.exit_code != EXIT_SUCCESS) {
       FUZZTEST_LOG(ERROR) << "Early stop requested for test " << env.test_name
-                          << " with failure exit code " << exit_code;
+                          << " with failure exit code "
+                          << stop_request.exit_code;
     } else {
       FUZZTEST_LOG(INFO) << "Skip updating corpus database due to early stop "
                             "requested without failure.";
@@ -717,6 +725,16 @@
   }
   stop_condition->SetStopTime(env.stop_at);
 
+  auto SaveStopReasonAndGetExitCode = [&]() -> int {
+    StopCondition::StopRequest stop_request;
+    const bool stop_requested = stop_condition->StopRequested(&stop_request);
+    if (!stop_requested) return EXIT_SUCCESS;
+    if (!env.stop_reason_file.empty()) {
+      WriteToLocalFile(env.stop_reason_file, stop_request.reason);
+    }
+    return stop_request.exit_code;
+  };
+
   if (!env.corpus_to_files.empty()) {
     Centipede::CorpusToFiles(env, env.corpus_to_files);
     return EXIT_SUCCESS;
@@ -732,14 +750,14 @@
 
   if (!env.for_each_blob.empty()) {
     ForEachBlob(env, *stop_condition);
-    return stop_condition->ExitCode();
+    return SaveStopReasonAndGetExitCode();
   }
 
   if (!env.minimize_crash_file_path.empty()) {
     ByteArray crashy_input;
     ReadFromLocalFile(env.minimize_crash_file_path, crashy_input);
     MinimizeCrash(crashy_input, env, callbacks_factory, *stop_condition);
-    return stop_condition->ExitCode();
+    return SaveStopReasonAndGetExitCode();
   }
 
   // Just export the corpus from a local dir and exit.
@@ -815,7 +833,7 @@
       }
       if (env.replay_crash) {
         ReplayCrash(updated_env, callbacks_factory, *stop_condition);
-        return stop_condition->ExitCode();
+        return SaveStopReasonAndGetExitCode();
       }
       if (env.export_crash) {
         return ExportCrash(updated_env);
@@ -829,7 +847,7 @@
                      absl::Seconds(1))
           << "Time limit per fuzz test must be at least 1 second.";
       UpdateCorpusDatabase(updated_env, callbacks_factory, *stop_condition);
-      return stop_condition->ExitCode();
+      return SaveStopReasonAndGetExitCode();
     }
   }
 
@@ -842,11 +860,14 @@
   std::string pcs_file_path;
   BinaryInfo binary_info = PopulateBinaryInfoAndSavePCsIfNecessary(
       env, callbacks_factory, pcs_file_path, *stop_condition);
+  if (stop_condition->StopRequested()) {
+    return SaveStopReasonAndGetExitCode();
+  }
 
   if (env.analyze) return Analyze(env);
 
   Fuzz(env, binary_info, pcs_file_path, callbacks_factory, *stop_condition);
-  return stop_condition->ExitCode();
+  return SaveStopReasonAndGetExitCode();
 
   // TODO: fniksic - Report the crash summary here if requested. What are the
   // binary identifier and the fuzz test name here?
diff --git a/centipede/centipede_main.cc b/centipede/centipede_main.cc
index e38dcaf..7983059 100644
--- a/centipede/centipede_main.cc
+++ b/centipede/centipede_main.cc
@@ -36,7 +36,7 @@
     const char msg[] = "\n[!] Ctrl-C pressed: winding down\n";
     [[maybe_unused]] auto write_res =
         write(STDERR_FILENO, msg, sizeof(msg) - 1);
-    global_stop_condition.RequestEarlyStop(EXIT_FAILURE);
+    global_stop_condition.RequestStop(EXIT_FAILURE, "Ctrl-C pressed");
   };
   sigaction(SIGINT, &sigact, nullptr);
 }
diff --git a/centipede/centipede_test.cc b/centipede/centipede_test.cc
index 58539ff..021805e 100644
--- a/centipede/centipede_test.cc
+++ b/centipede/centipede_test.cc
@@ -1294,8 +1294,9 @@
   EXPECT_THAT(callbacks.Mutate(GetMutationInputRefsFromDataInputs(inputs),
                                inputs.size()),
               IsEmpty());
-  EXPECT_TRUE(stop_condition.EarlyStopRequested());
-  EXPECT_EQ(stop_condition.ExitCode(), EXIT_FAILURE);
+  StopCondition::StopRequest stop_request;
+  EXPECT_TRUE(stop_condition.StopRequested(&stop_request));
+  EXPECT_EQ(stop_request.exit_code, EXIT_FAILURE);
 }
 
 TEST_F(CentipedeWithTemporaryLocalDir,
@@ -1386,6 +1387,27 @@
             HasSubstr("Mutate() succeeded")));
 }
 
+TEST_F(CentipedeWithTemporaryLocalDir, CentipedeMainWritesStopReason) {
+  TempCorpusDir tmp_dir{test_info_->name()};
+  Environment env;
+  env.workdir = tmp_dir.path() / "workdir";
+  env.binary =
+      absl::StrCat(GetDataDependencyFilepath(
+                       "centipede/testing/fuzz_target_with_custom_mutator")
+                       .c_str(),
+                   " --simulate_failure");
+  env.stop_reason_file = tmp_dir.path() / "stop_reason";
+  env.populate_binary_info = false;
+  fuzztest::internal::DefaultCallbacksFactory<
+      fuzztest::internal::CentipedeDefaultCallbacks>
+      callbacks;
+  const int ret = CentipedeMain(env, callbacks);
+  EXPECT_EQ(ret, EXIT_FAILURE);
+  std::string stop_reason_file_content;
+  ReadFromLocalFile(env.stop_reason_file, stop_reason_file_content);
+  EXPECT_THAT(stop_reason_file_content, HasSubstr("failed to mutate"));
+}
+
 TEST_F(CentipedeWithTemporaryLocalDir, EngineWorksInWorkerMode) {
   TempCorpusDir tmp_dir{test_info_->name()};
   Environment env;
diff --git a/centipede/command.cc b/centipede/command.cc
index 972a056..48ca89f 100644
--- a/centipede/command.cc
+++ b/centipede/command.cc
@@ -506,7 +506,8 @@
     const auto signal = WTERMSIG(exit_code);
     if (signal == SIGINT) {
       if (stop_condition != nullptr) {
-        stop_condition->RequestEarlyStop(EXIT_FAILURE);
+        stop_condition->RequestStop(
+            EXIT_FAILURE, "Command killed: signal=SIGINT (likely Ctrl-C)");
       }
       // When the user kills Centipede via ^C, they are unlikely to be
       // interested in any of the subprocesses' outputs. Also, ^C terminates all
diff --git a/centipede/command.h b/centipede/command.h
index b574a84..33c1d14 100644
--- a/centipede/command.h
+++ b/centipede/command.h
@@ -91,7 +91,7 @@
 
   // Waits for the command execution and returns the exit status if the
   // execution finishes within `deadline`. Must be called only when the command
-  // is executing. If interrupted, may call `stop_condition->RequestEarlyStop()`
+  // is executing. If interrupted, may call `stop_condition->RequestStop()`
   // (see stop.h).
   std::optional<int> Wait(absl::Time deadline,
                           StopCondition* stop_condition = nullptr);
diff --git a/centipede/minimize_crash.cc b/centipede/minimize_crash.cc
index 3a75160..0e12382 100644
--- a/centipede/minimize_crash.cc
+++ b/centipede/minimize_crash.cc
@@ -148,7 +148,8 @@
   ByteArray original_crashy_input(crashy_input.begin(), crashy_input.end());
   if (callbacks->Execute(env.binary, {original_crashy_input}, batch_result)) {
     FUZZTEST_LOG(INFO) << "The original crashy input did not crash; exiting";
-    stop_condition.RequestEarlyStop(EXIT_FAILURE);
+    stop_condition.RequestStop(EXIT_FAILURE,
+                               "The original crashy input did not crash");
     return;
   }
 
@@ -167,9 +168,9 @@
     }
   }  // The threads join here.
 
-  if (stop_condition.EarlyStopRequested()) return;
+  if (stop_condition.StopRequested()) return;
   if (!queue.SmallerCrashesFound()) {
-    stop_condition.RequestEarlyStop(EXIT_FAILURE);
+    stop_condition.RequestStop(EXIT_FAILURE, "Smaller crashes not found");
   }
 }
 
diff --git a/centipede/minimize_crash_test.cc b/centipede/minimize_crash_test.cc
index 03c9ad4..80dc5c0 100644
--- a/centipede/minimize_crash_test.cc
+++ b/centipede/minimize_crash_test.cc
@@ -89,25 +89,32 @@
   const WorkDir wd{env};
   MinimizerMockFactory factory;
   StopCondition stop_condition;
+  StopCondition::StopRequest stop_request;
 
   // Test with a non-crashy input.
+  stop_request = {};
   MinimizeCrash({1, 2, 3}, env, factory, stop_condition);
-  EXPECT_EQ(stop_condition.ExitCode(), EXIT_FAILURE);
+  (void)stop_condition.StopRequested(&stop_request);
+  EXPECT_EQ(stop_request.exit_code, EXIT_FAILURE);
 
   ByteArray expected_minimized = {'f', 'u', 'z'};
 
   // Test with a crashy input that can't be minimized further.
-  stop_condition.ClearEarlyStopRequest();
+  stop_condition.ClearStopRequest();
+  stop_request = {};
   MinimizeCrash(expected_minimized, env, factory, stop_condition);
-  EXPECT_EQ(stop_condition.ExitCode(), EXIT_FAILURE);
+  (void)stop_condition.StopRequested(&stop_request);
+  EXPECT_EQ(stop_request.exit_code, EXIT_FAILURE);
 
   // Test the actual minimization.
   ByteArray original_crasher = {'f', '.', '.', '.', '.', '.', '.', '.',
                                 '.', '.', '.', 'u', '.', '.', '.', '.',
                                 '.', '.', '.', '.', '.', '.', 'z'};
-  stop_condition.ClearEarlyStopRequest();
+  stop_condition.ClearStopRequest();
+  stop_request = {};
   MinimizeCrash(original_crasher, env, factory, stop_condition);
-  EXPECT_EQ(stop_condition.ExitCode(), EXIT_SUCCESS);
+  (void)stop_condition.StopRequested(&stop_request);
+  EXPECT_EQ(stop_request.exit_code, EXIT_SUCCESS);
   // Collect the new crashers from the crasher dir.
   std::vector<ByteArray> crashers;
   for (auto const &dir_entry : std::filesystem::directory_iterator{
diff --git a/centipede/stop.cc b/centipede/stop.cc
index fb7204a..7b1e35a 100644
--- a/centipede/stop.cc
+++ b/centipede/stop.cc
@@ -14,37 +14,78 @@
 
 #include "./centipede/stop.h"
 
+#include <algorithm>
+#include <array>
 #include <atomic>
+#include <cstdlib>
+#include <cstring>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <thread>  // NOLINT: for std::this_thread
 
 #include "absl/time/clock.h"
 #include "absl/time/time.h"
 
 namespace fuzztest::internal {
 
-bool StopCondition::EarlyStopRequested() const {
-  return early_stop_.load(std::memory_order_acquire).is_requested;
-}
+StopCondition::StopCondition()
+    : reason_(std::make_unique<std::array<char, kReasonBufferSize>>()) {}
 
-void StopCondition::ClearEarlyStopRequest() {
-  early_stop_.store({}, std::memory_order_release);
+void StopCondition::ClearStopRequest() {
+  if (!stop_requested_.load(std::memory_order_acquire)) {
+    return;
+  }
+  // Wait until the request is fully written.
+  while (!stop_request_ready_.load(std::memory_order_acquire)) {
+    std::this_thread::yield();
+  }
+
+  exit_code_ = EXIT_SUCCESS;
+  reason_len_ = 0;
+
+  stop_request_ready_.store(false, std::memory_order_release);
+  stop_requested_.store(false, std::memory_order_release);
 }
 
 void StopCondition::SetStopTime(absl::Time stop_time) {
   stop_time_ = stop_time;
 }
 
-void StopCondition::RequestEarlyStop(int exit_code) {
-  early_stop_.store({exit_code, true}, std::memory_order_release);
+bool StopCondition::StopRequested(StopRequest* request) const {
+  if (!stop_requested_.load(std::memory_order_acquire)) {
+    return false;
+  }
+  if (request == nullptr) {
+    return true;
+  }
+  // Wait until the request is fully written.
+  while (!stop_request_ready_.load(std::memory_order_acquire)) {
+    std::this_thread::yield();
+  }
+  request->exit_code = exit_code_;
+  request->reason = std::string(reason_->data(), reason_len_);
+  return true;
+}
+
+void StopCondition::RequestStop(int exit_code, std::string_view reason) {
+  // Only write the reason if it hasn't been requested yet, to avoid races
+  // overwriting it, although races are rare.
+  if (stop_requested_.exchange(true)) return;
+  exit_code_ = exit_code;
+  // Copy up to the capacity to avoid memory allocation and make it safe to call
+  // in signal handlers.
+  reason_len_ = std::min(reason_->size(), reason.size());
+  if (reason_len_ > 0) {
+    std::memcpy(reason_->data(), reason.data(), reason_len_);
+  }
+  stop_request_ready_.store(true, std::memory_order_release);
 }
 
 absl::Time StopCondition::GetStopTime() const { return stop_time_; }
 
 bool StopCondition::ShouldStop() const {
-  return EarlyStopRequested() || stop_time_ < absl::Now();
-}
-
-int StopCondition::ExitCode() const {
-  return early_stop_.load(std::memory_order_acquire).exit_code;
+  return StopRequested() || stop_time_ < absl::Now();
 }
 
 }  // namespace fuzztest::internal
diff --git a/centipede/stop.h b/centipede/stop.h
index 071fbeb..025694a 100644
--- a/centipede/stop.h
+++ b/centipede/stop.h
@@ -15,8 +15,13 @@
 #ifndef THIRD_PARTY_CENTIPEDE_STOP_H_
 #define THIRD_PARTY_CENTIPEDE_STOP_H_
 
+#include <array>
 #include <atomic>
+#include <cstddef>
 #include <cstdlib>
+#include <memory>
+#include <string>
+#include <string_view>
 
 #include "absl/time/time.h"
 
@@ -25,32 +30,40 @@
 // Encapsulates the stop condition state for Centipede.
 class StopCondition {
  public:
-  StopCondition() = default;
+  StopCondition();
 
   StopCondition(const StopCondition&) = delete;
   StopCondition& operator=(const StopCondition&) = delete;
   StopCondition(StopCondition&&) = delete;
   StopCondition& operator=(StopCondition&&) = delete;
 
-  // Clears the request to stop early.
+  // Clears the request to stop.
   //
   // REQUIRES: Must be called before starting concurrent threads that may invoke
   // the other methods on this object instance. Specifically, calling this
-  // function concurrently with `EarlyStopRequested()` is not thread-safe.
-  void ClearEarlyStopRequest();
+  // function concurrently with `StopRequested()` is not thread-safe.
+  void ClearStopRequest();
 
-  // Returns whether `RequestEarlyStop()` was called or not since the most
-  // recent call to `ClearEarlyStopRequest()` (if any).
+  struct StopRequest {
+    int exit_code = EXIT_SUCCESS;
+    std::string reason;
+  };
+
+  // Returns whether `RequestStop()` was called or not since the most
+  // recent call to `ClearStopRequest()` (if any). If `request` is not
+  // null, copy the stop request to the referred instance when stop is
+  // requested.
   //
-  // ENSURES: Thread-safe unless with `ClearEarlyStopRequest()`.
-  bool EarlyStopRequested() const;
+  // ENSURES: Thread-safe unless with `ClearStopRequest()`.
+  bool StopRequested(StopRequest* request = nullptr) const;
 
   // Requests that Centipede soon stops whatever it is doing (fuzzing,
   // minimizing reproducer, etc.), with `exit_code` indicating success (zero) or
-  // failure (non-zero).
+  // failure (non-zero). The `reason` will be capped to the internal buffer
+  // size.
   //
-  // ENSURES: Thread-safe and safe to call from signal handlers.
-  void RequestEarlyStop(int exit_code);
+  // ENSURES: Thread-safe and safe to call in signal handlers.
+  void RequestStop(int exit_code, std::string_view reason);
 
   // Sets the stop time.
   //
@@ -60,34 +73,31 @@
   void SetStopTime(absl::Time stop_time);
 
   // Returns true iff it is time to stop, either because the stopping time has
-  // been reached or `RequestEarlyStop()` was called since the most recent call
-  // to `ClearEarlyStopRequestAndSetStopTime()` (if any).
+  // been reached or `RequestStop()` was called since the most recent call
+  // to `ClearStopRequest()` (if any).
   //
   // ENSURES: Thread-safe.
   bool ShouldStop() const;
 
   // Returns the stop time set from the recent
-  // `ClearEarlyStopRequestAndSetStopTime()`, or `absl::InfiniteFuture()` if it
+  // `SetStopTime()`, or `absl::InfiniteFuture()` if it
   // was not set.
   //
   // ENSURES: Thread-safe.
   absl::Time GetStopTime() const;
 
-  // Returns the value most recently passed to `RequestEarlyStop()` or 0 if
-  // `RequestEarlyStop()` was not called since the most recent call to
-  // `ClearEarlyStopRequestAndSetStopTime()` (if any).
-  //
-  // ENSURES: Thread-safe.
-  int ExitCode() const;
-
  private:
-  struct EarlyStop {
-    int exit_code = EXIT_SUCCESS;
-    bool is_requested = false;
-  };
-  static_assert(std::atomic<EarlyStop>::is_always_lock_free);
-  std::atomic<EarlyStop> early_stop_{EarlyStop{}};
+  static constexpr size_t kReasonBufferSize = 500;
+
   absl::Time stop_time_ = absl::InfiniteFuture();
+  // Set to true when `RequestStop` is requested.
+  std::atomic<bool> stop_requested_ = false;
+  int exit_code_ = EXIT_SUCCESS;
+  std::unique_ptr<std::array<char, kReasonBufferSize>> reason_;
+  size_t reason_len_ = 0;
+  // Set to true when the fields between `stop_requested_` and
+  // `stop_request_ready_` are fully set.
+  std::atomic<bool> stop_request_ready_ = false;
 };
 }  // namespace fuzztest::internal
 
diff --git a/centipede/stop_test.cc b/centipede/stop_test.cc
new file mode 100644
index 0000000..b2abd6a
--- /dev/null
+++ b/centipede/stop_test.cc
@@ -0,0 +1,152 @@
+// Copyright 2026 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/stop.h"
+
+#include <atomic>
+#include <cstdlib>
+#include <string>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "absl/time/clock.h"
+#include "absl/time/time.h"
+#include "./centipede/thread_pool.h"
+
+namespace fuzztest::internal {
+namespace {
+
+using ::testing::AllOf;
+using ::testing::AnyOf;
+using ::testing::Field;
+using ::testing::StartsWith;
+
+TEST(StopConditionTest, InitialState) {
+  StopCondition stop_condition;
+  EXPECT_FALSE(stop_condition.StopRequested());
+  EXPECT_FALSE(stop_condition.ShouldStop());
+  EXPECT_EQ(stop_condition.GetStopTime(), absl::InfiniteFuture());
+}
+
+TEST(StopConditionTest, RequestStopShortReason) {
+  StopCondition stop_condition;
+  stop_condition.RequestStop(EXIT_FAILURE, "test reason");
+
+  EXPECT_TRUE(stop_condition.StopRequested());
+  EXPECT_TRUE(stop_condition.ShouldStop());
+
+  StopCondition::StopRequest request;
+  EXPECT_TRUE(stop_condition.StopRequested(&request));
+  EXPECT_EQ(request.exit_code, EXIT_FAILURE);
+  EXPECT_EQ(request.reason, "test reason");
+}
+
+TEST(StopConditionTest, RequestStopLongReasonIsCapped) {
+  StopCondition stop_condition;
+  std::string long_reason(1000, 'a');
+  stop_condition.RequestStop(EXIT_FAILURE, long_reason);
+
+  StopCondition::StopRequest request;
+  EXPECT_TRUE(stop_condition.StopRequested(&request));
+  EXPECT_EQ(request.exit_code, EXIT_FAILURE);
+  // 100 is a reasonable size to check.
+  EXPECT_GE(request.reason.size(), 100);
+  EXPECT_THAT(long_reason, StartsWith(request.reason));
+}
+
+TEST(StopConditionTest, RequestStopOnlyFirstCallTakesEffect) {
+  StopCondition stop_condition;
+  stop_condition.RequestStop(EXIT_FAILURE, "first reason");
+  stop_condition.RequestStop(EXIT_SUCCESS, "second reason");
+
+  StopCondition::StopRequest request;
+  EXPECT_TRUE(stop_condition.StopRequested(&request));
+  EXPECT_EQ(request.exit_code, EXIT_FAILURE);
+  EXPECT_EQ(request.reason, "first reason");
+}
+
+TEST(StopConditionTest, ClearStopRequest) {
+  StopCondition stop_condition;
+  stop_condition.RequestStop(EXIT_FAILURE, "some reason");
+  EXPECT_TRUE(stop_condition.StopRequested());
+
+  stop_condition.ClearStopRequest();
+  EXPECT_FALSE(stop_condition.StopRequested());
+
+  StopCondition::StopRequest request;
+  EXPECT_FALSE(stop_condition.StopRequested(&request));
+
+  // Can request stop again after clearing
+  stop_condition.RequestStop(EXIT_SUCCESS, "new reason");
+  EXPECT_TRUE(stop_condition.StopRequested(&request));
+  EXPECT_EQ(request.exit_code, EXIT_SUCCESS);
+  EXPECT_EQ(request.reason, "new reason");
+}
+
+TEST(StopConditionTest, SetStopTime) {
+  StopCondition stop_condition;
+  absl::Time past_time = absl::Now() - absl::Seconds(10);
+  stop_condition.SetStopTime(past_time);
+  EXPECT_EQ(stop_condition.GetStopTime(), past_time);
+  EXPECT_TRUE(stop_condition.ShouldStop());
+  EXPECT_FALSE(stop_condition.StopRequested());
+}
+
+TEST(StopConditionTest, ConcurrentStopRequests) {
+  StopCondition stop_condition;
+  ThreadPool requesters(2);
+  std::atomic<bool> stop_testing = false;
+  requesters.Schedule([&] {
+    while (!stop_testing) {
+      stop_condition.RequestStop(/*exit_code=*/1234,
+                                 "stop request from thread 1");
+    }
+  });
+  requesters.Schedule([&] {
+    while (!stop_testing) {
+      stop_condition.RequestStop(/*exit_code=*/5678,
+                                 "stop request from thread 2");
+    }
+  });
+  bool got_stop_request_from_thread_1 = false;
+  bool got_stop_request_from_thread_2 = false;
+  const absl::Time start = absl::Now();
+  while (absl::Now() - start < absl::Seconds(3)) {
+    StopCondition::StopRequest stop_request;
+    if (stop_condition.StopRequested(&stop_request)) {
+      ASSERT_THAT(
+          stop_request,
+          AnyOf(AllOf(Field(&StopCondition::StopRequest::exit_code, 1234),
+                      Field(&StopCondition::StopRequest::reason,
+                            "stop request from thread 1")),
+                AllOf(Field(&StopCondition::StopRequest::exit_code, 5678),
+                      Field(&StopCondition::StopRequest::reason,
+                            "stop request from thread 2"))));
+      if (stop_request.exit_code == 1234) {
+        got_stop_request_from_thread_1 = true;
+      } else {
+        got_stop_request_from_thread_2 = true;
+      }
+      stop_condition.ClearStopRequest();
+    }
+  }
+  stop_testing = true;
+  EXPECT_TRUE(got_stop_request_from_thread_1);
+  EXPECT_TRUE(got_stop_request_from_thread_2);
+  // Requester threads would be joined at the end, so if they got stuck the test
+  // would time out.
+}
+
+}  // namespace
+}  // namespace fuzztest::internal
diff --git a/centipede/testing/fuzz_target_with_custom_mutator.cc b/centipede/testing/fuzz_target_with_custom_mutator.cc
index dcd3735..babca79 100644
--- a/centipede/testing/fuzz_target_with_custom_mutator.cc
+++ b/centipede/testing/fuzz_target_with_custom_mutator.cc
@@ -24,8 +24,8 @@
 #include "./common/defs.h"
 
 ABSL_FLAG(bool, simulate_failure, false,
-          "If true, the binary will return EXIT_FAILURE to simulate a "
-          "failure.");
+          "If true, the binary will exit with EXIT_FAILURE in the custom "
+          "mutator to simulate a failure.");
 
 using fuzztest::internal::ByteSpan;
 using fuzztest::internal::MutantRef;
@@ -39,6 +39,10 @@
 
   void* Mutate(void* input,
                const fuzztest::internal::ExecutionMetadata& metadata) override {
+    if (absl::GetFlag(FLAGS_simulate_failure)) {
+      std::exit(EXIT_FAILURE);
+    }
+
     const auto* ba =
         reinterpret_cast<const fuzztest::internal::ByteArray*>(input);
     return reinterpret_cast<void*>(new fuzztest::internal::ByteArray{*ba});
@@ -64,9 +68,6 @@
 
 int main(int argc, char** absl_nonnull argv) {
   absl::ParseCommandLine(argc, argv);
-  if (absl::GetFlag(FLAGS_simulate_failure)) {
-    return EXIT_FAILURE;
-  }
   CustomMutatorRunnerCallbacks runner_callbacks;
   return fuzztest::internal::RunnerMain(argc, argv, runner_callbacks);
 }
diff --git a/fuzztest/internal/centipede_adaptor.cc b/fuzztest/internal/centipede_adaptor.cc
index f13801b..c74427e 100644
--- a/fuzztest/internal/centipede_adaptor.cc
+++ b/fuzztest/internal/centipede_adaptor.cc
@@ -362,19 +362,21 @@
       sigemptyset(&new_sigact.sa_mask);
       new_sigact.sa_handler = [](int signum) {
         Runtime::instance().SetTerminationRequested();
-        global_stop_condition.RequestEarlyStop(EXIT_SUCCESS);
         const int fd =
             GetStderrFdDup() != -1 ? GetStderrFdDup() : STDERR_FILENO;
         if (signum == SIGTERM) {
+          global_stop_condition.RequestStop(EXIT_SUCCESS, "SIGTERM received");
           constexpr char kMsg[] =
               "\n[!] SIGTERM received - stopping fuzzing.\n";
           write(fd, kMsg, sizeof(kMsg) - 1);
           return;
         } else if (signum == SIGHUP) {
+          global_stop_condition.RequestStop(EXIT_SUCCESS, "SIGHUP received");
           constexpr char kMsg[] = "\n[!] SIGHUP received - stopping fuzzing.\n";
           write(fd, kMsg, sizeof(kMsg) - 1);
           return;
         } else if (signum == SIGINT) {
+          global_stop_condition.RequestStop(EXIT_SUCCESS, "SIGINT received");
           constexpr char kMsg[] = "\n[!] SIGINT received - stopping fuzzing.\n";
           write(fd, kMsg, sizeof(kMsg) - 1);
           return;
@@ -438,7 +440,7 @@
         << "Termination status must be Exited if not Signaled";
     return static_cast<int>(std::get<ExitCodeT>(status.Status()));
   }
-  global_stop_condition.ClearEarlyStopRequest();
+  global_stop_condition.ClearStopRequest();
   global_stop_condition.SetStopTime(absl::InfiniteFuture());
   static absl::NoDestructor<DefaultCallbacksFactory<CentipedeDefaultCallbacks>>
       factory;