From 86997a438c67d014f2d8738931d88c8990154f59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Sep 2026 16:57:03 +0200 Subject: [PATCH] Clamp the moved pass start to now, document the watchdog and task constraints, test the threshold --- esphome/core/application.h | 12 ++++-- tests/components/core/test_blocking_scope.cpp | 40 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 467157182b..d3e4e28fb2 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -646,6 +646,9 @@ class LoopBlockingGuard { /// the warning exists to find, and wrapping them in this scope hides the /// bug instead of fixing it. If in doubt, leave the warning in. /// +/// Main loop task only. The watchdog is not fed inside the scope, so the +/// work must still finish within the watchdog timeout. +/// /// { /// UnavoidableBlockingScope scope; /// bring_up_radio(); @@ -654,9 +657,12 @@ class UnavoidableBlockingScope { public: UnavoidableBlockingScope() : started_(millis()) {} ~UnavoidableBlockingScope() { - // Move the pass start forward by the time spent here; the guard measures - // from that start, and components reading it as "now" get a fresher value - App.set_loop_component_start_time_(App.get_loop_component_start_time() + (millis() - this->started_)); + // Move the pass start forward by the time spent here, but never past now: + // nested scopes count the inner stretch twice, and a start in the future + // would underflow the guard's subtraction + const uint32_t now = millis(); + const uint32_t moved = App.get_loop_component_start_time() + (now - this->started_); + App.set_loop_component_start_time_(static_cast(now - moved) < 0 ? now : moved); } UnavoidableBlockingScope(const UnavoidableBlockingScope &) = delete; UnavoidableBlockingScope &operator=(const UnavoidableBlockingScope &) = delete; diff --git a/tests/components/core/test_blocking_scope.cpp b/tests/components/core/test_blocking_scope.cpp index aa2895f8e3..92822d0efd 100644 --- a/tests/components/core/test_blocking_scope.cpp +++ b/tests/components/core/test_blocking_scope.cpp @@ -27,8 +27,46 @@ TEST(UnavoidableBlockingScope, ExcludesItsDurationFromThePass) { TEST(UnavoidableBlockingScope, ZeroLengthScopeLeavesTheStartAlone) { const uint32_t pass_start = millis(); LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); { UnavoidableBlockingScope scope; } - EXPECT_LE(App.get_loop_component_start_time() - pass_start, 1u); + EXPECT_LE(App.get_loop_component_start_time() - pass_start, millis() - before); +} + +// Nesting counts the inner stretch twice; the start must still never pass now +TEST(UnavoidableBlockingScope, NestedScopesNeverMoveTheStartPastNow) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + { + UnavoidableBlockingScope outer; + { + UnavoidableBlockingScope inner; + delay(30); + } + delay(5); + } + const uint32_t now = millis(); + EXPECT_GE(static_cast(now - App.get_loop_component_start_time()), 0); + EXPECT_GE(App.get_loop_component_start_time() - pass_start, 35u); +} + +class DummyComponent : public Component {}; + +// The excused stretch must neither warn nor ratchet the component's threshold +TEST(UnavoidableBlockingScope, ExcusedStretchDoesNotRatchetTheThreshold) { + DummyComponent component; + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + } + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_EQ(threshold_after, threshold_before); } } // namespace esphome