[core] Add UnavoidableBlockingScope for blocking that cannot be shortened (#19020)

This commit is contained in:
J. Nick Koston
2026-10-11 18:48:41 -10:00
committed by GitHub
parent 19eeab0385
commit f8076fe90b
3 changed files with 137 additions and 0 deletions
+33
View File
@@ -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<int32_t>(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
+1
View File
@@ -51,6 +51,7 @@ class MillisInternal {
}
friend class Application;
friend class LoopBlockingGuard;
friend class UnavoidableBlockingScope;
};
} // namespace esphome
@@ -0,0 +1,103 @@
#include <gtest/gtest.h>
#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<int32_t>(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