Clean up the crash minimizer interface and rewrite the implementation.

This is for minimizing crashes during corpus database update, which needs crash signature matching.

PiperOrigin-RevId: 992342097
diff --git a/centipede/BUILD b/centipede/BUILD
index 6e1f7a1..3c045ca 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -380,11 +380,10 @@
         ":stop",
         ":thread_pool",
         ":util",
-        ":workdir",
         "@abseil-cpp//absl/base:core_headers",
+        "@abseil-cpp//absl/container:flat_hash_set",
         "@abseil-cpp//absl/synchronization",
         "@com_google_fuzztest//common:defs",
-        "@com_google_fuzztest//common:hash",
         "@com_google_fuzztest//common:logging",
     ],
 )
@@ -775,6 +774,7 @@
         ":minimize_crash",
         ":pc_info",
         ":periodic_action",
+        ":runner_result",
         ":seed_corpus_maker_lib",
         ":stats",
         ":stop",
@@ -1522,9 +1522,9 @@
         ":minimize_crash",
         ":runner_result",
         ":stop",
-        ":util",
-        ":workdir",
         "@abseil-cpp//absl/base:nullability",
+        "@abseil-cpp//absl/container:flat_hash_set",
+        "@abseil-cpp//absl/random",
         "@abseil-cpp//absl/types:span",
         "@com_google_fuzztest//common:defs",
         "@com_google_fuzztest//common:test_util",
diff --git a/centipede/centipede_interface.cc b/centipede/centipede_interface.cc
index e6ba1eb..4cd9f75 100644
--- a/centipede/centipede_interface.cc
+++ b/centipede/centipede_interface.cc
@@ -26,6 +26,7 @@
 #include <memory>
 #include <string>
 #include <string_view>
+#include <system_error>  // NOLINT
 #include <utility>
 #include <vector>
 
@@ -55,6 +56,7 @@
 #include "./centipede/minimize_crash.h"
 #include "./centipede/pc_info.h"
 #include "./centipede/periodic_action.h"
+#include "./centipede/runner_result.h"
 #include "./centipede/seed_corpus_maker_lib.h"
 #include "./centipede/stats.h"
 #include "./centipede/stop.h"
@@ -109,6 +111,58 @@
   }
 }
 
+void MinimizeCrash(const Environment& env,
+                   CentipedeCallbacksFactory& callbacks_factory,
+                   StopCondition& stop_condition) {
+  ByteArray crashy_input;
+  ReadFromLocalFile(env.minimize_crash_file_path, crashy_input);
+
+  BatchResult batch_result;
+  {
+    ScopedCentipedeCallbacks scoped_callbacks(callbacks_factory, env,
+                                              stop_condition);
+    CreateLocalDirRemovedAtExit(TemporaryLocalDirPath());
+    if (scoped_callbacks.callbacks()->Execute(env.binary, {crashy_input},
+                                              batch_result)) {
+      FUZZTEST_LOG(ERROR) << "The original crashy input did not crash; exiting";
+      stop_condition.RequestStop(EXIT_FAILURE,
+                                 "The original crashy input did not crash");
+      return;
+    }
+  }
+
+  const auto result =
+      MinimizeCrash(crashy_input, env, callbacks_factory,
+                    batch_result.failure_signature(), stop_condition);
+  if (!result.has_value()) {
+    stop_condition.RequestStop(EXIT_FAILURE, "No smaller crash found");
+    return;
+  }
+
+  const auto output_dir =
+      std::filesystem::path{WorkDir{env}.CrashReproducerDirPaths().MyShard()};
+  const auto mkdir_status = RemoteMkdir(output_dir.string());
+  if (!mkdir_status.ok()) {
+    const auto error_msg =
+        absl::StrCat("Failed to create the minimized crasher dir ",
+                     output_dir.string(), ": ", mkdir_status);
+    FUZZTEST_LOG(ERROR) << error_msg;
+    stop_condition.RequestStop(EXIT_FAILURE, error_msg);
+    return;
+  }
+  const auto result_input_hash = Hash(result->input);
+  const auto output_path = (output_dir / result_input_hash).string();
+  const auto write_status = RemoteFileSetContents(output_path, result->input);
+  if (!write_status.ok()) {
+    const auto error_msg =
+        absl::StrCat("Failed to write the minimized crasher to ", output_path,
+                     ": ", write_status);
+    FUZZTEST_LOG(ERROR) << error_msg;
+    stop_condition.RequestStop(EXIT_FAILURE, error_msg);
+    return;
+  }
+}
+
 // Loads corpora from work dirs provided in `env.args`, if there are two args
 // provided, analyzes differences. If there is one arg provided, reports the
 // function coverage. Returns EXIT_SUCCESS on success, EXIT_FAILURE otherwise.
@@ -755,9 +809,7 @@
   }
 
   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);
+    MinimizeCrash(env, callbacks_factory, *stop_condition);
     return SaveStopReasonAndGetExitCode();
   }
 
diff --git a/centipede/minimize_crash.cc b/centipede/minimize_crash.cc
index 4399dcb..66ef9a5 100644
--- a/centipede/minimize_crash.cc
+++ b/centipede/minimize_crash.cc
@@ -17,12 +17,14 @@
 #include <algorithm>
 #include <cstddef>
 #include <cstdlib>
-#include <filesystem>  // NOLINT
+#include <optional>
 #include <string>
 #include <string_view>
+#include <utility>
 #include <vector>
 
 #include "absl/base/thread_annotations.h"
+#include "absl/container/flat_hash_set.h"
 #include "absl/synchronization/mutex.h"
 #include "./centipede/centipede_callbacks.h"
 #include "./centipede/environment.h"
@@ -31,64 +33,57 @@
 #include "./centipede/stop.h"
 #include "./centipede/thread_pool.h"
 #include "./centipede/util.h"
-#include "./centipede/workdir.h"
 #include "./common/defs.h"
-#include "./common/hash.h"
 #include "./common/logging.h"  // IWYU pragma: keep
 
 namespace fuzztest::internal {
 
-// Work queue for the minimizer.
+namespace {
+
+// The minimizer state shared by all worker threads.
 // Thread-safe.
-struct MinimizerWorkQueue {
+class MinimizerState {
  public:
-  // Creates the queue.
-  // `crash_dir_path` is the directory path where new crashers are written.
-  // `crasher` is the initial crashy input.
-  MinimizerWorkQueue(const std::string_view crash_dir_path,
-                     const ByteArray crasher)
-      : crash_dir_path_(crash_dir_path), crashers_{ByteArray(crasher)} {
-    std::filesystem::create_directory(crash_dir_path_);
+  MinimizerState(size_t capacity, ByteSpan initial_crasher)
+      : capacity_(capacity),
+        crashers_{{initial_crasher.begin(), initial_crasher.end()}} {}
+
+  std::vector<ByteArray> GetCurrentCrashers() const {
+    absl::MutexLock lock(mutex_);
+    return {crashers_.begin(), crashers_.end()};
   }
 
-  // Returns up to `max_num_crashers` most recently added crashers.
-  std::vector<ByteArray> GetRecentCrashers(size_t max_num_crashers) {
+  void AddCrasher(ByteArray crasher, std::string description) {
     absl::MutexLock lock(mutex_);
-    size_t num_crashers_to_return =
-        std::min(crashers_.size(), max_num_crashers);
-    return {crashers_.end() - num_crashers_to_return, crashers_.end()};
+    if (!result_.has_value() || crasher.size() < result_->input.size()) {
+      result_ = {crasher, std::move(description)};
+    }
+    crashers_.insert(std::move(crasher));
+    while (crashers_.size() > capacity_) {
+      crashers_.erase(std::max_element(
+          crashers_.begin(), crashers_.end(),
+          [](const auto& a, const auto& b) { return a.size() < b.size(); }));
+    }
   }
 
-  // Adds `crasher` to the queue, writes it to `crash_dir_path_/Hash(crasher)`.
-  // The crasher must be smaller than the original one.
-  void AddCrasher(ByteArray crasher) {
+  std::optional<MinimizeCrashResult> GetResult() const {
     absl::MutexLock lock(mutex_);
-    FUZZTEST_CHECK_LT(crasher.size(), crashers_.front().size());
-    crashers_.emplace_back(crasher);
-    // Write the crasher to disk.
-    auto hash = Hash(crasher);
-    auto dir = crash_dir_path_;
-    std::string file_path = dir.append(hash);
-    WriteToLocalFile(file_path, crasher);
-  }
-
-  // Returns true if new smaller crashes were found.
-  bool SmallerCrashesFound() const {
-    absl::MutexLock lock(mutex_);
-    return crashers_.size() > 1;
+    return result_;
   }
 
  private:
   mutable absl::Mutex mutex_;
-  const std::filesystem::path crash_dir_path_;
-  std::vector<ByteArray> crashers_ ABSL_GUARDED_BY(mutex_);
+  size_t capacity_ ABSL_GUARDED_BY(mutex_);
+  // Keep at most `capacity_` crashers to avoid being stuck in local minimum.
+  absl::flat_hash_set<ByteArray> crashers_ ABSL_GUARDED_BY(mutex_);
+  std::optional<MinimizeCrashResult> result_ ABSL_GUARDED_BY(mutex_);
 };
 
-// Performs a minimization loop in one thread.
-static void MinimizeCrash(const Environment& env,
-                          CentipedeCallbacksFactory& callbacks_factory,
-                          MinimizerWorkQueue& queue,
-                          StopCondition& stop_condition) {
+void MinimizeCrashInOneThread(const Environment& env,
+                              CentipedeCallbacksFactory& callbacks_factory,
+                              std::string_view crash_signature,
+                              MinimizerState& state,
+                              StopCondition& stop_condition) {
   ScopedCentipedeCallbacks scoped_callback(callbacks_factory, env,
                                            stop_condition);
   auto callbacks = scoped_callback.callbacks();
@@ -96,84 +91,86 @@
 
   size_t num_batches = env.num_runs / env.batch_size;
   for (size_t i = 0; i < num_batches; ++i) {
+    if (stop_condition.ShouldStop()) break;
     FUZZTEST_LOG_EVERY_POW_2(INFO)
         << "[" << i << "] Minimizing... Interrupt to stop";
-    if (stop_condition.ShouldStop()) break;
-    // Get up to kMaxNumCrashersToGet most recent crashers. We don't want just
-    // the most recent crasher to avoid being stuck in local minimum.
-    constexpr size_t kMaxNumCrashersToGet = 20;
-    const auto recent_crashers = queue.GetRecentCrashers(kMaxNumCrashersToGet);
-    FUZZTEST_CHECK(!recent_crashers.empty());
+
+    const auto crashers = state.GetCurrentCrashers();
+    FUZZTEST_CHECK(!crashers.empty());
     // Compute the minimal known crasher size.
-    size_t min_known_size = recent_crashers.front().size();
-    for (const auto &crasher : recent_crashers) {
+    size_t min_known_size = crashers.front().size();
+    for (const auto& crasher : crashers) {
       min_known_size = std::min(min_known_size, crasher.size());
     }
 
+    std::vector<ByteSpan> smaller_mutants;
     // Create several mutants that are smaller than the current smallest one.
     //
     // Currently, we do this by calling the vanilla mutator and
     // discarding all inputs that are too large.
-    // TODO(kcc): modify the Mutate() interface such that max_len can be passed.
     //
+    // TODO(xinhaoyuan): modify the Mutate() interface such that size hint can
+    // be passed.
     const std::vector<Mutant> mutants = callbacks->Mutate(
-        GetMutationInputRefsFromDataInputs(recent_crashers), env.batch_size);
-    std::vector<ByteSpan> smaller_mutants;
-    for (const auto &m : mutants) {
-      if (m.data.size() < min_known_size) smaller_mutants.push_back(m.data);
+        GetMutationInputRefsFromDataInputs(crashers), env.batch_size);
+    for (const auto& m : mutants) {
+      if (m.data.size() < min_known_size) {
+        smaller_mutants.push_back(m.data);
+      }
     }
 
-    // Execute all mutants. If a new crasher is found, add it to `queue`.
-    if (!callbacks->Execute(env.binary, smaller_mutants, batch_result)) {
-      size_t crash_inputs_idx = batch_result.num_outputs_read();
-      FUZZTEST_CHECK_LT(crash_inputs_idx, smaller_mutants.size());
-      const auto new_crasher = smaller_mutants[crash_inputs_idx];
-      FUZZTEST_LOG(INFO) << "Crasher: size: " << new_crasher.size() << ": "
-                         << AsPrintableString(new_crasher, /*max_len=*/40);
-      queue.AddCrasher({new_crasher.begin(), new_crasher.end()});
+    if (smaller_mutants.empty()) {
+      continue;
     }
+
+    // Try smaller mutants first to minimize the size of the new crasher.
+    std::sort(smaller_mutants.begin(), smaller_mutants.end(),
+              [](const auto& a, const auto& b) { return a.size() < b.size(); });
+
+    // Execute all mutants. If a new crasher is found, add it to `state`.
+    if (callbacks->Execute(env.binary, smaller_mutants, batch_result)) {
+      continue;
+    }
+
+    if (batch_result.failure_signature() != crash_signature) {
+      continue;
+    }
+
+    size_t crash_inputs_idx = batch_result.num_outputs_read();
+    FUZZTEST_CHECK_LT(crash_inputs_idx, smaller_mutants.size());
+    const auto& new_crasher = smaller_mutants[crash_inputs_idx];
+    FUZZTEST_LOG(INFO) << "Crasher: size: " << new_crasher.size() << ": "
+                       << AsPrintableString(new_crasher, /*max_len=*/40);
+    state.AddCrasher({new_crasher.begin(), new_crasher.end()},
+                     std::move(batch_result.failure_description()));
   }
 }
 
-void MinimizeCrash(ByteSpan crashy_input, const Environment& env,
-                   CentipedeCallbacksFactory& callbacks_factory,
-                   StopCondition& stop_condition) {
-  ScopedCentipedeCallbacks scoped_callback(callbacks_factory, env,
-                                           stop_condition);
-  auto callbacks = scoped_callback.callbacks();
+}  // namespace
 
-  FUZZTEST_LOG(INFO) << "MinimizeCrash: trying the original crashy input";
-  CreateLocalDirRemovedAtExit(TemporaryLocalDirPath());
-
-  BatchResult batch_result;
-  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.RequestStop(EXIT_FAILURE,
-                               "The original crashy input did not crash");
-    return;
-  }
-
+std::optional<MinimizeCrashResult> MinimizeCrash(
+    ByteSpan crashy_input, const Environment& env,
+    CentipedeCallbacksFactory& callbacks_factory,
+    std::string_view crash_signature, StopCondition& stop_condition) {
   FUZZTEST_LOG(INFO) << "Starting the crash minimization loop in "
-                     << env.num_threads << "threads";
+                     << env.num_threads << " threads";
 
-  MinimizerWorkQueue queue(WorkDir{env}.CrashReproducerDirPaths().MyShard(),
-                           original_crashy_input);
+  // Minimize with 20 intermediate crashers empirically - may be adjusted later.
+  MinimizerState state(/*capacity=*/20, crashy_input);
 
   {
     ThreadPool threads{static_cast<int>(env.num_threads)};
     for (size_t i = 0; i < env.num_threads; ++i) {
-      threads.Schedule([&env, &callbacks_factory, &queue, &stop_condition]() {
+      threads.Schedule([&env, &callbacks_factory, &state, &stop_condition,
+                        crash_signature]() {
         CreateLocalDirRemovedAtExit(TemporaryLocalDirPath());
-        MinimizeCrash(env, callbacks_factory, queue, stop_condition);
+        MinimizeCrashInOneThread(env, callbacks_factory, crash_signature, state,
+                                 stop_condition);
       });
     }
   }  // The threads join here.
 
-  if (stop_condition.StopRequested()) return;
-  if (!queue.SmallerCrashesFound()) {
-    stop_condition.RequestStop(EXIT_FAILURE, "Smaller crashes not found");
-  }
+  return state.GetResult();
 }
 
 }  // namespace fuzztest::internal
diff --git a/centipede/minimize_crash.h b/centipede/minimize_crash.h
index a7e21d5..5cc20b2 100644
--- a/centipede/minimize_crash.h
+++ b/centipede/minimize_crash.h
@@ -15,6 +15,10 @@
 #ifndef THIRD_PARTY_CENTIPEDE_MINIMIZE_CRASH_H_
 #define THIRD_PARTY_CENTIPEDE_MINIMIZE_CRASH_H_
 
+#include <optional>
+#include <string>
+#include <string_view>
+
 #include "./centipede/centipede_callbacks.h"
 #include "./centipede/environment.h"
 #include "./centipede/stop.h"
@@ -22,14 +26,19 @@
 
 namespace fuzztest::internal {
 
-// Tries to minimize `crashy_input`.
+struct MinimizeCrashResult {
+  ByteArray input;
+  std::string description;
+};
+
+// Tries to minimize `crashy_input` with `crash_signature`.
 // Uses `callbacks_factory` to create `env.num_threads` workers.
-// Requests to stop with EXIT_FAILURE using `stop_condition` if no smaller
-// crasher was found or if the original input didn't crash. Stores the newly
-// found crashy inputs in `WorkDir{env}.CrashReproducerDirPath()`.
-void MinimizeCrash(ByteSpan crashy_input, const Environment& env,
-                   CentipedeCallbacksFactory& callbacks_factory,
-                   StopCondition& stop_condition);
+// Returns a minimized crash if found, otherwise nullopt.
+// Stops when `stop_condition` is requested/due.
+std::optional<MinimizeCrashResult> MinimizeCrash(
+    ByteSpan crashy_input, const Environment& env,
+    CentipedeCallbacksFactory& callbacks_factory,
+    std::string_view crash_signature, StopCondition& stop_condition);
 
 }  // namespace fuzztest::internal
 
diff --git a/centipede/minimize_crash_test.cc b/centipede/minimize_crash_test.cc
index 80dc5c0..5b27047 100644
--- a/centipede/minimize_crash_test.cc
+++ b/centipede/minimize_crash_test.cc
@@ -15,32 +15,33 @@
 #include "./centipede/minimize_crash.h"
 
 #include <cstdlib>
-#include <filesystem>  // NOLINT
 #include <string>
 #include <string_view>
-#include <vector>
+#include <utility>
 
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
 #include "absl/base/nullability.h"
+#include "absl/container/flat_hash_set.h"
+#include "absl/random/random.h"
 #include "absl/types/span.h"
 #include "./centipede/centipede_callbacks.h"
 #include "./centipede/environment.h"
 #include "./centipede/runner_result.h"
 #include "./centipede/stop.h"
-#include "./centipede/util.h"
-#include "./centipede/workdir.h"
 #include "./common/defs.h"
 #include "./common/test_util.h"
 
 namespace fuzztest::internal {
 namespace {
 
+using ::testing::UnorderedElementsAre;
+
 // A mock for CentipedeCallbacks.
 class MinimizerMock : public CentipedeCallbacks {
  public:
-  MinimizerMock(const Environment& env)
-      : CentipedeCallbacks(env, internal_stop_condition_) {}
+  MinimizerMock(const Environment& env, StopCondition& stop_condition)
+      : CentipedeCallbacks(env, stop_condition) {}
 
   // Runs FuzzMe() on every input, imitates failure if FuzzMe() returns true.
   bool Execute(std::string_view binary, absl::Span<const ByteSpan> inputs,
@@ -49,6 +50,11 @@
     for (auto input : inputs) {
       if (FuzzMe(input)) {
         batch_result.exit_code() = EXIT_FAILURE;
+        // Set signature differently to test signature matching behavior.
+        batch_result.failure_signature() =
+            input[0] == 'f' ? "signature one" : "signature two";
+        batch_result.failure_description() =
+            input[0] == 'f' ? "description one" : "description two";
         return false;
       }
       ++batch_result.num_outputs_read();
@@ -57,18 +63,16 @@
   }
 
  private:
-  // Returns true on inputs that look like 'f???u???z', false otherwise.
-  // The minimal input on which this function returns true is 'fuz'.
+  // Returns true on inputs that look like '[fz]+', false otherwise.
+  // The minimal input on which this function returns true is 'f' or 'z', with
+  // different crash signatures.
   bool FuzzMe(ByteSpan data) {
     if (data.empty()) return false;
-    if (data.front() == 'f' && data[data.size() / 2] == 'u' &&
-        data.back() == 'z') {
-      return true;
+    for (const auto c : data) {
+      if (c != 'f' && c != 'z') return false;
     }
-    return false;
+    return true;
   }
-
-  StopCondition internal_stop_condition_;
 };
 
 // Factory that creates/destroys MinimizerMock.
@@ -76,56 +80,78 @@
  public:
   CentipedeCallbacks* absl_nonnull create(
       const Environment& env, StopCondition& stop_condition) override {
-    return new MinimizerMock(env);
+    return new MinimizerMock(env, stop_condition);
   }
   void destroy(CentipedeCallbacks *cb) override { delete cb; }
 };
 
-TEST(MinimizeTest, MinimizeTest) {
-  TempDir tmp_dir{test_info_->name()};
+TEST(MinimizeTest, FailsWhenCrasherCannotBeMinimized) {
   Environment env;
-  env.workdir = tmp_dir.path();
   env.num_runs = 100000;
-  const WorkDir wd{env};
   MinimizerMockFactory factory;
   StopCondition stop_condition;
   StopCondition::StopRequest stop_request;
 
-  // Test with a non-crashy input.
+  const ByteArray expected_minimized = {'f'};
   stop_request = {};
-  MinimizeCrash({1, 2, 3}, env, factory, stop_condition);
-  (void)stop_condition.StopRequested(&stop_request);
-  EXPECT_EQ(stop_request.exit_code, EXIT_FAILURE);
+  EXPECT_FALSE(MinimizeCrash(expected_minimized, env, factory, "signature one",
+                             stop_condition)
+                   .has_value());
+  EXPECT_FALSE(stop_condition.StopRequested());
+}
 
-  ByteArray expected_minimized = {'f', 'u', 'z'};
+TEST(MinimizeTest, FailsWhenSignatureDoesNotMatch) {
+  Environment env;
+  env.num_runs = 100000;
+  MinimizerMockFactory factory;
+  StopCondition stop_condition;
+  StopCondition::StopRequest stop_request;
 
-  // Test with a crashy input that can't be minimized further.
-  stop_condition.ClearStopRequest();
+  ByteArray original_crasher = {'f', 'f', 'f', 'f', 'f', 'f',
+                                'z', 'z', 'z', 'z', 'z', 'z'};
   stop_request = {};
-  MinimizeCrash(expected_minimized, env, factory, stop_condition);
-  (void)stop_condition.StopRequested(&stop_request);
-  EXPECT_EQ(stop_request.exit_code, EXIT_FAILURE);
+  EXPECT_FALSE(MinimizeCrash(original_crasher, env, factory, "bad signature",
+                             stop_condition)
+                   .has_value());
+  EXPECT_FALSE(stop_condition.StopRequested());
+}
 
-  // Test the actual minimization.
-  ByteArray original_crasher = {'f', '.', '.', '.', '.', '.', '.', '.',
-                                '.', '.', '.', 'u', '.', '.', '.', '.',
-                                '.', '.', '.', '.', '.', '.', 'z'};
-  stop_condition.ClearStopRequest();
-  stop_request = {};
-  MinimizeCrash(original_crasher, env, factory, stop_condition);
-  (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{
-           wd.CrashReproducerDirPaths().MyShard()}) {
-    ByteArray crasher;
-    const std::string &path = dir_entry.path();
-    ReadFromLocalFile(path, crasher);
-    EXPECT_LT(crasher.size(), original_crasher.size());
-    crashers.push_back(crasher);
+TEST(MinimizeTest, MinimizesWithSignature) {
+  TempDir tmp_dir{test_info_->name()};
+  Environment env;
+  env.num_runs = 100000;
+  MinimizerMockFactory factory;
+
+  ByteArray original_crasher = {'f', 'f', 'f', 'f', 'f', 'f',
+                                'z', 'z', 'z', 'z', 'z', 'z'};
+  constexpr size_t kNumTrials = 30;
+  absl::BitGen rng;
+  absl::flat_hash_set<ByteArray> minimized_crashers;
+  StopCondition stop_condition;
+  for (size_t i = 0; i < kNumTrials; ++i) {
+    env.seed = rng();
+    auto result = MinimizeCrash(original_crasher, env, factory, "signature one",
+                                stop_condition);
+    ASSERT_TRUE(result.has_value());
+    EXPECT_EQ(result->description, "description one");
+    EXPECT_FALSE(stop_condition.StopRequested());
+    minimized_crashers.insert(std::move(result->input));
   }
-  EXPECT_THAT(crashers, testing::Contains(expected_minimized));
+  EXPECT_THAT(minimized_crashers, UnorderedElementsAre(ByteArray{'f'}));
+
+  minimized_crashers.clear();
+  ByteArray original_crasher_alt = {'z', 'z', 'z', 'z', 'z', 'z',
+                                    'f', 'f', 'f', 'f', 'f', 'f'};
+  for (size_t i = 0; i < kNumTrials; ++i) {
+    env.seed = rng();
+    auto result = MinimizeCrash(original_crasher_alt, env, factory,
+                                "signature two", stop_condition);
+    ASSERT_TRUE(result.has_value());
+    EXPECT_EQ(result->description, "description two");
+    EXPECT_FALSE(stop_condition.StopRequested());
+    minimized_crashers.insert(std::move(result->input));
+  }
+  EXPECT_THAT(minimized_crashers, UnorderedElementsAre(ByteArray{'z'}));
 }
 
 }  // namespace
diff --git a/centipede/test_util.sh b/centipede/test_util.sh
index 6983a52..672bb54 100644
--- a/centipede/test_util.sh
+++ b/centipede/test_util.sh
@@ -104,7 +104,8 @@
   local -r file="$2"
   local -r expected_found="$3"
   # Make the shell option change below local.
-  local -r saved_opts="$(set +o)"
+  local -r saved_short_opts="$-"
+  local -r saved_long_opts="$(set +o)"
   set -o pipefail
   if ! fileop ls "${file}" > /dev/null; then
     die "Expected file ${file} doesn't exist"
@@ -127,7 +128,8 @@
       die "^^^ File ${file} contains unexpected regex /${regex}/"
     fi
   fi
-  eval "${saved_opts}"
+  eval "${saved_long_opts}"
+  set -"${saved_short_opts}"
 }
 
 # Makes sure that string "$1" exists in file "$2". Works for local and CNS.
diff --git a/centipede/testing/minimize_crash_test.sh b/centipede/testing/minimize_crash_test.sh
index 7505164..eed107d 100755
--- a/centipede/testing/minimize_crash_test.sh
+++ b/centipede/testing/minimize_crash_test.sh
@@ -16,7 +16,7 @@
 
 # Tests the --minimize_crash flag.
 
-set -eu
+set -euo pipefail
 
 if ! [[ -e "${TEST_UTIL_SH:=$(dirname "$0")/../test_util.sh}" ]]; then
   echo "TEST_UTIL_SH must be set to the location of :test_util_sh" >&2