Compare commits

..
Author SHA1 Message Date
J. Nick Koston e7a5258118 Use MillisInternal in the scope and say setup() is not timed by the guard 2026-09-07 18:22:52 +02:00
J. Nick Koston d7dea9e74a Point at WatchdogManager for work longer than the watchdog timeout 2026-09-07 17:33:48 +02:00
J. Nick Koston 2307d811bb Note that later start time reads in the pass see the moved start 2026-09-07 17:31:34 +02:00
J. Nick Koston d6d2540823 Test that work outside the scope still ratchets 2026-09-07 17:22:25 +02:00
J. Nick Koston c1272a72aa Name the loop pass cases the scope is for 2026-09-07 17:19:43 +02:00
J. Nick Koston a4ec1791e2 Make nested scopes exact, point the examples at loop(), keep the test component alive 2026-09-07 17:10:39 +02:00
J. Nick Koston 86997a438c Clamp the moved pass start to now, document the watchdog and task constraints, test the threshold 2026-09-07 16:57:03 +02:00
J. Nick Koston 717fbb1373 [core] Say when UnavoidableBlockingScope must not be used 2026-09-07 16:40:25 +02:00
J. Nick Koston 56f6d7bc6d [core] Add UnavoidableBlockingScope for blocking that cannot be shortened
Some work has no shorter form: bringing up a radio, the first connect of
a network stack, a key generation whose cost is the algorithm. Wrapping
it in this scope moves the loop pass start forward by its duration, so
the blocking warning keeps reporting everything else in the pass and the
component's threshold does not ratchet over it. The comment says what it
is for and that it must never hide code that could be made faster.
2026-09-07 16:38:56 +02:00
3 changed files with 154 additions and 0 deletions
+50
View File
@@ -389,6 +389,7 @@ class Application {
friend Component;
friend class Scheduler;
friend class LoopBlockingGuard;
friend class UnavoidableBlockingScope;
#ifdef USE_RUNTIME_STATS
friend class runtime_stats::RuntimeStatsCollector;
#endif
@@ -631,6 +632,55 @@ 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 done from a loop pass that cannot be made shorter and
/// cannot be split across passes: turning on a radio, the first Wi-Fi
/// connect, a key generation whose cost is the algorithm itself. The warning
/// then keeps reporting everything else in the pass, and the component's
/// threshold does not ratchet up over the one step nothing can be done about.
///
/// Never use it to paper over a problem that can be solved. A slow driver
/// call, a loop that could be a state machine, a computation that could be
/// cached or deferred, a blocking read that could be polled: those are what
/// 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.
///
/// Only work timed by a LoopBlockingGuard is affected, that is a component's
/// loop() or a scheduler callback; setup() is not timed by the guard, so the
/// scope has no effect on the warning there. Main loop task only. The watchdog is not fed inside the
/// scope, so the work must finish within the watchdog timeout, or be paired
/// with a watchdog::WatchdogManager that raises the timeout for the same
/// stretch. Scopes may nest; the outermost one decides how much of the pass
/// is left out.
/// App.get_loop_component_start_time() reads later in the same pass return
/// the moved start, so elapsed time across the scope needs millis().
///
/// void MyComponent::loop() {
/// if (this->needs_key_) {
/// 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, so an
// outer scope overrides an inner one instead of adding to it; never past
// now, which would underflow the guard's subtraction
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