Skip to content

Commit e969742

Browse files
committed
fix(fault_manager): allow healing_threshold == 0 (single-event heal)
Threshold validation was too strict: it rewrote healing_threshold=0 to 3, which broke action_status_bridge's single-event healing (heal on one PASSED). Require confirmation_threshold < 0 <= healing_threshold instead. Verified both bridge integration tests pass 5/5.
1 parent 6920ba4 commit e969742

6 files changed

Lines changed: 42 additions & 15 deletions

File tree

src/ros2_medkit_fault_manager/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ The fault manager uses an AUTOSAR DEM-style debounce model:
150150

151151
The counter is always clamped to `[confirmation_threshold, healing_threshold]`, so a long run of
152152
one-sided events cannot push it out to the integer limits and delay the opposite transition.
153-
`confirmation_threshold < 0 < healing_threshold` is required; invalid thresholds fall back to safe
154-
defaults with a warning.
153+
`confirmation_threshold < 0 <= healing_threshold` is required (`healing_threshold = 0` heals on a
154+
single PASSED event); invalid thresholds fall back to safe defaults with a warning.
155155

156156
`CONFIRMED` and `HEALED` are **latched** (hysteresis): once reached, the status holds until the
157157
counter reaches the opposite threshold, so a single opposite-direction event cannot flip it. As a

src/ros2_medkit_fault_manager/design/index.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ becomes active again is not back in the default (CONFIRMED-only) list until the
239239
up to ``healing_threshold - confirmation_threshold`` events. During that window ``occurrence_count``
240240
and ``last_occurred`` still reflect the activity.
241241

242-
Thresholds must satisfy ``confirmation_threshold < 0 < healing_threshold``; the node validates the
242+
Thresholds must satisfy ``confirmation_threshold < 0 <= healing_threshold`` (``healing_threshold == 0``
243+
means heal on a single PASSED event); the node validates the
243244
merged per-entity config at startup, logs a warning, and falls back to safe defaults if not. When
244245
healing is disabled, any HEALED row left by a previous (healing-enabled) run is reclassified to
245246
CLEARED once at startup so it does not behave inconsistently under the latch.

src/ros2_medkit_fault_manager/include/ros2_medkit_fault_manager/fault_storage.hpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ struct DebounceConfig {
4949
/// Whether healing is enabled. When true, faults can transition to HEALED status.
5050
bool healing_enabled{false};
5151

52-
/// Healing threshold (positive). When healing is enabled, the fault is HEALED once the counter
53-
/// reaches this value. The counter is always clamped to this upper bound, even when healing is
52+
/// Healing threshold (non-negative; 0 means heal on a single PASSED event). When healing is enabled,
53+
/// the fault is HEALED once the counter reaches this value. The counter is always clamped to this
54+
/// upper bound, even when healing is
5455
/// disabled, so a heal heartbeat cannot drive it off to INT32_MAX; healing_enabled only controls
5556
/// whether reaching the bound produces a HEALED status.
5657
/// Default: 3 (3 more PASSED than FAILED events to heal).
@@ -72,8 +73,9 @@ int32_t clamp_debounce_counter(int32_t counter, const DebounceConfig & config);
7273
/// callers handle that. This is the single source of truth shared by both storage backends.
7374
std::string compute_debounce_status(int32_t counter, const std::string & current_status, const DebounceConfig & config);
7475

75-
/// Validate a (merged) debounce config in place, enforcing confirmation_threshold < 0 < healing_threshold.
76-
/// Offending fields are reset to safe defaults (-1 / 3). Returns true if the config was already valid.
76+
/// Validate a (merged) debounce config in place, enforcing confirmation_threshold < 0 <= healing_threshold
77+
/// (healing_threshold == 0 means heal on a single PASSED event). Offending fields are reset to safe
78+
/// defaults (-1 / 3). Returns true if the config was already valid.
7779
bool sanitize_debounce_config(DebounceConfig & config);
7880

7981
/// Internal fault state stored in memory

src/ros2_medkit_fault_manager/src/fault_manager_node.cpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ FaultManagerNode::FaultManagerNode(const rclcpp::NodeOptions & options) : Node("
166166
global_config_.auto_confirm_after_sec = auto_confirm_after_sec_;
167167
if (!sanitize_debounce_config(global_config_)) {
168168
RCLCPP_WARN(get_logger(),
169-
"Invalid debounce thresholds (need confirmation_threshold < 0 < healing_threshold); using safe "
169+
"Invalid debounce thresholds (need confirmation_threshold < 0 <= healing_threshold); using safe "
170170
"defaults: confirmation=%d healing=%d",
171171
global_config_.confirmation_threshold, global_config_.healing_threshold);
172172
}
@@ -186,7 +186,7 @@ FaultManagerNode::FaultManagerNode(const rclcpp::NodeOptions & options) : Node("
186186
if (!entity_thresholds_file.empty()) {
187187
auto entries = EntityThresholdResolver::load_from_yaml(entity_thresholds_file);
188188
// Validate each override against the global config: the merge is field by field, so an override
189-
// can break confirmation_threshold < 0 < healing_threshold even when the global config is valid.
189+
// can break confirmation_threshold < 0 <= healing_threshold even when the global config is valid.
190190
for (auto & entry : entries) {
191191
DebounceConfig merged = global_config_;
192192
if (entry.confirmation_threshold) {
@@ -197,7 +197,7 @@ FaultManagerNode::FaultManagerNode(const rclcpp::NodeOptions & options) : Node("
197197
}
198198
if (!sanitize_debounce_config(merged)) {
199199
RCLCPP_WARN(get_logger(),
200-
"Entity '%s' debounce thresholds invalid (need confirmation_threshold < 0 < healing_threshold); "
200+
"Entity '%s' debounce thresholds invalid (need confirmation_threshold < 0 <= healing_threshold); "
201201
"using safe defaults",
202202
entry.prefix.c_str());
203203
if (entry.confirmation_threshold) {
@@ -1226,7 +1226,7 @@ DebounceConfig FaultManagerNode::resolve_config(const std::string & source_id) c
12261226
config = threshold_resolver_->resolve(source_id, global_config_);
12271227
}
12281228
// Defensive: the merged config is validated at load time, but never hand the storage backend a
1229-
// config that violates confirmation_threshold < 0 < healing_threshold (the counter would stick).
1229+
// config that violates confirmation_threshold < 0 <= healing_threshold (the counter would stick).
12301230
sanitize_debounce_config(config);
12311231
return config;
12321232
}

src/ros2_medkit_fault_manager/src/fault_storage.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ bool sanitize_debounce_config(DebounceConfig & config) {
6060
config.confirmation_threshold = -1;
6161
valid = false;
6262
}
63-
if (config.healing_threshold <= 0) {
63+
// healing_threshold == 0 is valid: it means "heal on a single PASSED event" (the counter reaches
64+
// the threshold at 0). Only a negative threshold is rejected.
65+
if (config.healing_threshold < 0) {
6466
config.healing_threshold = 3;
6567
valid = false;
6668
}

src/ros2_medkit_fault_manager/test/test_fault_manager.cpp

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -729,12 +729,34 @@ TEST(DebounceHelpers, SanitizeDebounceConfigFixesBadThresholds) {
729729
DebounceConfig good;
730730
EXPECT_TRUE(sanitize_debounce_config(good)); // defaults are valid
731731

732+
// healing_threshold == 0 is valid (heal on a single PASSED event); leave it untouched.
733+
DebounceConfig single_event_heal;
734+
single_event_heal.confirmation_threshold = -1;
735+
single_event_heal.healing_threshold = 0;
736+
EXPECT_TRUE(sanitize_debounce_config(single_event_heal));
737+
EXPECT_EQ(single_event_heal.healing_threshold, 0);
738+
732739
DebounceConfig bad;
733-
bad.confirmation_threshold = 0;
734-
bad.healing_threshold = 0;
740+
bad.confirmation_threshold = 0; // must be < 0
741+
bad.healing_threshold = -1; // must be >= 0
735742
EXPECT_FALSE(sanitize_debounce_config(bad));
736743
EXPECT_LT(bad.confirmation_threshold, 0);
737-
EXPECT_GT(bad.healing_threshold, 0);
744+
EXPECT_GE(bad.healing_threshold, 0);
745+
}
746+
747+
// healing_threshold == 0 heals on a single PASSED event (used by action_status_bridge).
748+
TEST_F(FaultStorageTest, HealingThresholdZeroHealsOnSinglePassed) {
749+
rclcpp::Clock clock;
750+
DebounceConfig config;
751+
config.confirmation_threshold = -1;
752+
config.healing_enabled = true;
753+
config.healing_threshold = 0;
754+
755+
storage_.report_fault_event("F", ReportFault::Request::EVENT_FAILED, Fault::SEVERITY_ERROR, "e", "/n", clock.now(),
756+
config);
757+
ASSERT_EQ(storage_.get_fault("F")->status, Fault::STATUS_CONFIRMED);
758+
storage_.report_fault_event("F", ReportFault::Request::EVENT_PASSED, 0, "", "/n", clock.now(), config);
759+
EXPECT_EQ(storage_.get_fault("F")->status, Fault::STATUS_HEALED);
738760
}
739761

740762
TEST(DebounceHelpers, ComputeDebounceStatusLatches) {

0 commit comments

Comments
 (0)