diff --git a/esphome/core/application.h b/esphome/core/application.h index b91bc187e6f..f9f98a67070 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -390,6 +390,7 @@ class Application { friend Component; friend class Scheduler; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -632,6 +633,38 @@ class LoopBlockingGuard { static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); }; +/// Leaves a stretch of the current loop pass out of the blocking warning. +/// +/// Only for work that cannot be made shorter or split across passes: radio +/// bring-up, a first network connect, a key generation. Work that could be +/// cached, deferred, polled or turned into a state machine stays outside, so +/// the warning keeps finding it. +/// +/// Affects only work timed by LoopBlockingGuard: loop() and scheduler +/// callbacks, not setup(). Main loop task only. The watchdog is not fed, so +/// pair long work with watchdog::WatchdogManager. Scopes may nest. +/// Afterwards App.get_loop_component_start_time() can be earlier than a +/// millis() read taken before the scope; time the work with millis() at both ends. +/// +/// UnavoidableBlockingScope scope; +/// this->generate_key_(); +class UnavoidableBlockingScope { + public: + UnavoidableBlockingScope() : started_(MillisInternal::get()), pass_start_(App.get_loop_component_start_time()) {} + ~UnavoidableBlockingScope() { + // Move the pass start seen at entry forward by the time spent here, clamped to now + const uint32_t now = MillisInternal::get(); + const uint32_t moved = this->pass_start_ + (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; + + private: + uint32_t started_; + uint32_t pass_start_; +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 7297d223572..c9b84540780 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -51,6 +51,7 @@ class MillisInternal { } friend class Application; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; }; } // namespace esphome diff --git a/tests/components/core/test_blocking_scope.cpp b/tests/components/core/test_blocking_scope.cpp new file mode 100644 index 00000000000..8bdeec19ea2 --- /dev/null +++ b/tests/components/core/test_blocking_scope.cpp @@ -0,0 +1,103 @@ +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome { + +// The scope must push the pass start forward by the time it covers and by +// nothing else, so the blocking guard sees only the work outside it +TEST(UnavoidableBlockingScope, ExcludesItsDurationFromThePass) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + ASSERT_EQ(App.get_loop_component_start_time(), pass_start); + + const uint32_t before = millis(); + { + UnavoidableBlockingScope scope; + delay(30); + } + const uint32_t excused = millis() - before; + + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 30u); + EXPECT_LE(moved, excused); +} + +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, millis() - before); +} + +// Nested scopes leave out the outer span exactly once, and never move the +// start past now +TEST(UnavoidableBlockingScope, NestedScopesExcuseTheOuterSpanOnce) { + const uint32_t pass_start = millis(); + LoopBlockingGuard guard(nullptr, nullptr, pass_start); + const uint32_t before = millis(); + { + UnavoidableBlockingScope outer; + { + UnavoidableBlockingScope inner; + delay(30); + } + delay(5); + } + const uint32_t excused = millis() - before; + const uint32_t moved = App.get_loop_component_start_time() - pass_start; + EXPECT_GE(moved, 35u); + EXPECT_LE(moved, excused); + EXPECT_GE(static_cast(millis() - App.get_loop_component_start_time()), 0); +} + +namespace { +// Static: the guard publishes the component to App and nothing clears it. +// One instance per test, since a ratcheted threshold is permanent +class DummyComponent : public Component {}; +DummyComponent &blocking_test_component(size_t index) { + static DummyComponent components[2]; + return components[index]; +} +} // namespace + +// The excused stretch must neither warn nor ratchet the component's threshold +TEST(UnavoidableBlockingScope, ExcusedStretchDoesNotRatchetTheThreshold) { + DummyComponent &component = blocking_test_component(0); + 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); +} + +// Work outside the scope is still measured and still ratchets +TEST(UnavoidableBlockingScope, WorkOutsideTheScopeStillRatchetsTheThreshold) { + DummyComponent &component = blocking_test_component(1); + uint32_t threshold_before = 0; + component.should_warn_of_blocking(0, threshold_before); + { + LoopBlockingGuard guard(&component, nullptr, millis()); + { + UnavoidableBlockingScope scope; + delay(20); + } + delay(WARN_IF_BLOCKING_OVER_CS * 10U + 20); + guard.finish(); + } + uint32_t threshold_after = 0; + component.should_warn_of_blocking(0, threshold_after); + EXPECT_GT(threshold_after, threshold_before); +} + +} // namespace esphome