Clear features when the input is rejected

PiperOrigin-RevId: 791212313
diff --git a/centipede/runner.cc b/centipede/runner.cc
index d998350..416d8cb 100644
--- a/centipede/runner.cc
+++ b/centipede/runner.cc
@@ -386,8 +386,7 @@
 // If `full_clear==true` clear all coverage anyway - useful to remove the
 // coverage accumulated during startup.
 __attribute__((noinline))  // so that we see it in profile.
-static void
-PrepareCoverage(bool full_clear) {
+static void PrepareCoverage(bool full_clear) {
   state.CleanUpDetachedTls();
   if (state.run_time_flags.path_level != 0) {
     state.ForEachTls([](ThreadLocalRunnerState &tls) {
@@ -445,12 +444,14 @@
 // Adds a kPCs and/or k8bitCounters feature to `g_features` based on arguments.
 // `idx` is a pc_index.
 // `counter_value` (non-zero) is a counter value associated with that PC.
-static void AddPcIndxedAndCounterToFeatures(size_t idx, uint8_t counter_value) {
+static void AddPcIndxedAndCounterToFeatures(
+    size_t idx, uint8_t counter_value,
+    const std::function<void(feature_t)> &feature_handler) {
   if (state.run_time_flags.use_pc_features) {
-    MaybeAddFeature(feature_domains::kPCs.ConvertToMe(idx));
+    feature_handler(feature_domains::kPCs.ConvertToMe(idx));
   }
   if (state.run_time_flags.use_counter_features) {
-    MaybeAddFeature(feature_domains::k8bitCounters.ConvertToMe(
+    feature_handler(feature_domains::k8bitCounters.ConvertToMe(
         Convert8bitCounterToNumber(idx, counter_value)));
   }
 }
@@ -463,66 +464,72 @@
 // LibFuzzer supports this return value as of 2022-07:
 // https://llvm.org/docs/LibFuzzer.html#rejecting-unwanted-inputs
 __attribute__((noinline))  // so that we see it in profile.
-static void
-PostProcessCoverage(int target_return_value) {
+static void PostProcessCoverage(int target_return_value) {
   state.g_features.clear();
 
-  if (target_return_value == -1) return;
+  std::function<void(feature_t)> feature_handler = MaybeAddFeature;
+  if (target_return_value == -1) {
+    // When suppressing a test, still iterate through all of the features as a
+    // side effect of iteration is zeroing them out.  But don't write the
+    // features anywhere.
+    feature_handler = [](feature_t feature) {};
+  }
 
   // Convert counters to features.
   state.pc_counter_set.ForEachNonZeroByte(
-      [](size_t idx, uint8_t value) {
-        AddPcIndxedAndCounterToFeatures(idx, value);
+      [&feature_handler](size_t idx, uint8_t value) {
+        AddPcIndxedAndCounterToFeatures(idx, value, feature_handler);
       },
       0, state.actual_pc_counter_set_size_aligned);
 
   // Convert data flow bit set to features.
   if (state.run_time_flags.use_dataflow_features) {
-    state.data_flow_feature_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kDataFlow.ConvertToMe(idx));
-    });
+    state.data_flow_feature_set.ForEachNonZeroBit(
+        [&feature_handler](size_t idx) {
+          feature_handler(feature_domains::kDataFlow.ConvertToMe(idx));
+        });
   }
 
   // Convert cmp bit set to features.
   if (state.run_time_flags.use_cmp_features) {
     // TODO(kcc): remove cmp_feature_set.
-    state.cmp_feature_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kCMP.ConvertToMe(idx));
+    state.cmp_feature_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kCMP.ConvertToMe(idx));
     });
-    state.cmp_eq_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kCMPEq.ConvertToMe(idx));
+    state.cmp_eq_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kCMPEq.ConvertToMe(idx));
     });
-    state.cmp_moddiff_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kCMPModDiff.ConvertToMe(idx));
+    state.cmp_moddiff_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kCMPModDiff.ConvertToMe(idx));
     });
-    state.cmp_hamming_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kCMPHamming.ConvertToMe(idx));
+    state.cmp_hamming_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kCMPHamming.ConvertToMe(idx));
     });
-    state.cmp_difflog_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kCMPDiffLog.ConvertToMe(idx));
+    state.cmp_difflog_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kCMPDiffLog.ConvertToMe(idx));
     });
   }
 
   // Convert path bit set to features.
   if (state.run_time_flags.path_level != 0) {
-    state.path_feature_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kBoundedPath.ConvertToMe(idx));
+    state.path_feature_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kBoundedPath.ConvertToMe(idx));
     });
   }
 
   // Iterate all threads and get features from TLS data.
-  state.ForEachTls([](ThreadLocalRunnerState &tls) {
+  state.ForEachTls([&feature_handler](ThreadLocalRunnerState &tls) {
     if (state.run_time_flags.callstack_level != 0) {
       RunnerCheck(tls.top_frame_sp >= tls.lowest_sp,
                   "bad values of tls.top_frame_sp and tls.lowest_sp");
       size_t sp_diff = tls.top_frame_sp - tls.lowest_sp;
-      MaybeAddFeature(feature_domains::kCallStack.ConvertToMe(sp_diff));
+      feature_handler(feature_domains::kCallStack.ConvertToMe(sp_diff));
     }
   });
 
   if (state.run_time_flags.callstack_level != 0) {
-    state.callstack_set.ForEachNonZeroBit([](size_t idx) {
-      MaybeAddFeature(feature_domains::kCallStack.ConvertToMe(idx));
+    state.callstack_set.ForEachNonZeroBit([&feature_handler](size_t idx) {
+      feature_handler(feature_domains::kCallStack.ConvertToMe(idx));
     });
   }
 
@@ -538,7 +545,7 @@
       // available. If a user domain ID is out of range, alias it to an existing
       // domain. This is kinder than silently dropping the feature.
       user_domain_id %= std::size(feature_domains::kUserDomains);
-      MaybeAddFeature(feature_domains::kUserDomains[user_domain_id].ConvertToMe(
+      feature_handler(feature_domains::kUserDomains[user_domain_id].ConvertToMe(
           user_feature_id));
       *p = 0;  // cleanup for the next iteration.
     }
@@ -549,8 +556,8 @@
   if (state.run_time_flags.use_pc_features ||
       state.run_time_flags.use_counter_features) {
     state.sancov_objects.ForEachNonZeroInlineCounter(
-        [](size_t idx, uint8_t counter_value) {
-          AddPcIndxedAndCounterToFeatures(idx, counter_value);
+        [&feature_handler](size_t idx, uint8_t counter_value) {
+          AddPcIndxedAndCounterToFeatures(idx, counter_value, feature_handler);
         });
   }
 }
@@ -654,9 +661,8 @@
 // Runs one input provided in file `input_path`.
 // Produces coverage data in file `input_path`-features.
 __attribute__((noinline))  // so that we see it in profile.
-static void
-ReadOneInputExecuteItAndDumpCoverage(const char *input_path,
-                                     RunnerCallbacks &callbacks) {
+static void ReadOneInputExecuteItAndDumpCoverage(const char *input_path,
+                                                 RunnerCallbacks &callbacks) {
   // Read the input.
   auto data = ReadBytesFromFilePath<uint8_t>(input_path);
 
diff --git a/centipede/testing/BUILD b/centipede/testing/BUILD
index 67cfe46..b88acf9 100644
--- a/centipede/testing/BUILD
+++ b/centipede/testing/BUILD
@@ -103,6 +103,10 @@
     name = "test_fuzz_target",
 )
 
+centipede_fuzz_target(
+    name = "random_rejecting_fuzz_target",
+)
+
 # Dummy fuzz target that uses the C++ RunnerCallbacks API to return seed inputs.
 cc_binary(
     name = "_seeded_fuzz_target",
@@ -339,6 +343,7 @@
     data = [
         ":test_fuzz_target",
         ":test_fuzz_target_non_pie",
+        "@com_google_fuzztest//centipede",
         "@com_google_fuzztest//centipede:test_util_sh",
     ],
 )
@@ -412,6 +417,16 @@
 )
 
 sh_test(
+    name = "runner_cleanup_test",
+    srcs = ["runner_cleanup_test.sh"],
+    data = [
+        ":random_rejecting_fuzz_target",
+        "@com_google_fuzztest//centipede",
+        "@com_google_fuzztest//centipede:test_util_sh",
+    ],
+)
+
+sh_test(
     name = "user_defined_features_test",
     srcs = ["user_defined_features_test.sh"],
     data = [
diff --git a/centipede/testing/random_rejecting_fuzz_target.cc b/centipede/testing/random_rejecting_fuzz_target.cc
new file mode 100644
index 0000000..84a8b89
--- /dev/null
+++ b/centipede/testing/random_rejecting_fuzz_target.cc
@@ -0,0 +1,36 @@
+// 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 <cstddef>
+#include <cstdint>
+
+static constexpr size_t kNumExtraFeatures = 10000;  // Any number.
+__attribute__((used, retain,
+               section("__centipede_extra_features"))) static uint64_t
+    extra_features[kNumExtraFeatures];
+
+static uint64_t run_number = 0;
+
+// "Randomly" fails half the time, but puts a user feature into the array when
+// it does.  This allows us to verify that inputs that return -1 are properly
+// cleaned up and do not leak user features into future inputs that return 0.
+extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
+  ++run_number;
+  if (run_number % 2 == 0) {
+    uint64_t domain = 1;
+    extra_features[0] = (domain << 32) | (1 << 8) | 1;
+    return -1;
+  }
+  return 0;
+}
diff --git a/centipede/testing/runner_cleanup_test.sh b/centipede/testing/runner_cleanup_test.sh
new file mode 100755
index 0000000..fdde02f
--- /dev/null
+++ b/centipede/testing/runner_cleanup_test.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# 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.
+
+# Tests cleanup of features for ignored tests.
+
+set -eu
+
+source "$(dirname "$0")/../test_util.sh"
+
+CENTIPEDE_TEST_SRCDIR="$(fuzztest::internal::get_centipede_test_srcdir)"
+
+echo "======== Check ignored tests don't count features"
+
+CENTIPEDE_TEST_SRCDIR="$(fuzztest::internal::get_centipede_test_srcdir)"
+fuzztest::internal::maybe_set_var_to_executable_path \
+  CENTIPEDE_BINARY "${CENTIPEDE_TEST_SRCDIR}/centipede"
+
+fuzztest::internal::maybe_set_var_to_executable_path \
+  REJECTING_BINARY "${CENTIPEDE_TEST_SRCDIR}/testing/random_rejecting_fuzz_target"
+
+WD="${TEST_TMPDIR}/WD"
+LOG="${TEST_TMPDIR}/log"
+
+"${CENTIPEDE_BINARY}" --binary="${REJECTING_BINARY}" --workdir="${WD}" \
+  --num_runs=10000  2>&1 |tee "${LOG}"
+
+fuzztest::internal::assert_regex_not_in_file "usr1: " "${LOG}"
+
+echo "PASS"