The classic std::vector swap-with-copy idiom (vector<T>(other).swap(other))
instantiates the iterator-range copy constructor, which pulls in
std::__throw_bad_array_new_length and the related typeinfo + vtable + dtor +
what() method (~118 B of stdlib RTTI). Build into a temp via reserve +
push_back instead, then move-assign:
- reserve uses ::operator new (throws bad_alloc, already linked).
- push_back without growth is the noexcept tail path.
- move-assign just swaps pointers, no allocation.
Same shrink semantics, saves ~128 B flash on ESP32. Also adds a fast-path
early return for the case where capacity already equals size (common after
a quiet period, since vector capacity only grows when crossing the doubling
threshold).
Project's .clang-tidy applies different naming rules to static methods
(ClassMethodCase, no suffix) vs instance methods (PrivateMethodCase /
ProtectedMethodCase, _ suffix required). The helper had a trailing _ but
was static, so clang-tidy flagged it. Drop static -- the helper is already
noinline'd and called only at trim time, so the hidden this arg is free
in any meaningful sense.
Project's .clang-tidy applies different naming rules to static methods
(ClassMethodCase, no suffix) vs instance methods (PrivateMethodCase /
ProtectedMethodCase, _ suffix required). The helper had a trailing _ but
was static, so clang-tidy flagged it. Drop static -- the helper is already
noinline'd and called only at trim time, so the hidden this arg is free
in any meaningful sense.
The three swap-with-copy lines in trim_freelist() inlined the
construct-swap-destruct dance per call site, costing ~440 B flash on ESP32.
Move the swap-shrink into a noinline private static helper so all three
callers share one body. Net flash cost of vector shrinking drops to ~296 B
(from ~444 B) while preserving the same RAM-reclaim behaviour.
shrink_to_fit() is a non-binding hint that the toolchain ignores, so the
swap-with-copy idiom is still required.
The freelist holds the boot-peak count of recycled SchedulerItem*; items_,
to_add_, and defer_queue_ hold the boot-peak vector *capacity* of live
SchedulerItem*. std::vector grows by doubling and retains capacity even when
items drain, so post-boot the vector slack can be larger than the freelist
itself. Swap each with a same-content copy to size them exactly to their
current contents.
Live items are preserved -- the swap-copy idiom builds the new vector from
the existing pointers, then the old (over-capacity) vector is destroyed.
Boot churn (component init, first sensor reads, retries) inflates the freelist
beyond the steady-state high-water mark. Without a one-shot trim that peak
would be retained forever. Adds Scheduler::trim_freelist() and schedules it
from Application::setup() to fire SCHEDULER_FREELIST_TRIM_DELAY_MS (10s) after
setup completes -- well past the bulk of post-setup async work.
Items currently in items_/to_add_/defer_queue_ are untouched; only the
freelist's recycled items are deleted. Post-trim, the freelist regrows to
the new (post-startup) high-water mark.
set_timer_common_'s self-key cancel path scans items_ and to_add_ linearly,
so the cancel-and-replace is O(N) in the global scheduler item count, not
O(1). Reword to describe the behavior without the complexity claim.
Address Copilot review feedback.
Adds an assertion that the observed peak pool size exceeds the old
MAX_POOL_SIZE=5 cap. Without this, a silent regression that re-introduced a
small cap could pass the existing pool_full_count == 0 invariant. Phase 5 + 6
of the fixture schedule 8 + 10 same-component timeouts, so the peak should
comfortably exceed 5.
Address Copilot review feedback on PR.
Migrates TimeoutFilterBase / TimeoutFilterLast / TimeoutFilterConfigured off
Component, mirroring the migration #16132 did for the other Component-based
sensor filters. They now arm/re-arm via App.scheduler.set_timeout(this, ...)
keyed on the filter pointer; the scheduler cancels and replaces any pending
arm in O(1) on each new_value(). Filters live for the program's lifetime, so
the self-key never dangles.
Net effect: this reverts the design from #11922, which moved timeout filters
off the scheduler specifically because the bounded SchedulerItem pool churned
on devices with many timers (LD2450 etc.). With #16172 replacing that pool
with an unbounded intrusive freelist, the original churn problem is gone and
the scheduler is once again the right primitive for this workload.
Per-instance footprint shrinks: lose Component (second vptr, packed bookkeeping
bytes, ComponentRuntimeStats block when runtime_stats: is enabled) and drop
the now-unused timeout_start_time_ field. get_setup_priority() and the loop()
poll go with it.
Sensor timeout-filter syntax is unchanged. No user-facing change.
The fixed MAX_POOL_SIZE=5 cap was the source of the heap churn the pool was
meant to prevent: any device with more than 5 concurrent timers (e.g. a board
with 30+ LD2450 sensors) hit a steady-state oscillation of recycle->delete and
acquire->new on every loop iteration.
Replace std::vector<SchedulerItem*> with a singly-linked freelist threaded
through SchedulerItem::next_free, which shares storage with `component` via an
anonymous union (zero per-item overhead -- the component pointer is dead while
pooled). Drop the cap entirely: the freelist quiesces at the application's
natural concurrent-timer high-water mark, which is the working set the device
already needs while those timers are active.
No std::vector means no growth-doubling slack and no realloc copies during
warm-up. Caller of get_item_from_pool_locked_() must overwrite item->component
before unlocking (already true at the sole call site); nullptr remains a valid
live `component` value for SELF_POINTER items, so we cannot pre-clear it.
Saves 4 B per ThrottleAverageFilter instance (28 B → 24 B on 32-bit).
have_nan_ is a single boolean that previously cost 4 B due to padding;
fold it into the high bit of n_ as a 31-bit + 1-bit bitfield.
To guarantee n_ cannot overflow under realistic configurations, cap
the YAML time_period at 24 h. At a pessimistic 1 kHz source rate the
counter peaks at 86.4M, leaving 25x headroom against 2^31. Anyone
needing multi-day rolling averages should be using a different
filtering strategy anyway.
Breaking change: configurations with throttle_average periods longer
than 24 h will now fail validation. No real-world configs are
expected to use such values.
The previous gate captured each subexpression in a named local
(high_frequency, elapsed, woke). Locals are easy to reuse later in
the function, and we have hit bugs from stale reuse before — most
recently is_high_frequency being read again instead of re-checked.
Inline the expression directly into do_component_phase so there is
nothing tempting to reuse downstream.