In time_64.cpp's true-rollover branch, bump the just-loaded `major`
local first and __atomic_store_n that value to millis_major, instead
of reading the global again for the store expression. Equivalent
under the held lock; clearer and avoids a second read.
scheduler.h indent: the NO_ATOMICS #else branch bodies read at 2
spaces while the sibling #ifdef/#elif branches read at 4. clang-format
refuses to normalise these consistently — every manual re-indent to 4
spaces gets reverted by the hook. Leaving as clang-format produces it.
Two fixes Copilot flagged on #15947:
1. Use __atomic_load_n to re-read last_millis under the lock, not a
plain read. The forward-progression branch (else if) writes
last_millis with __atomic_store_n without holding lock, so the
under-lock plain read would race with it and be UB in the C++
memory model.
2. Reload major from millis_major after acquiring the lock. The
unlocked load at the top of the function can be stale by the time
we get the lock: another thread may have completed a rollover
between the unlocked load and the lock acquisition, leaving our
local major behind by one. Without reload the function could
return a 64-bit timestamp that jumps backwards by ~2^32 ms (~49.7
days). The MULTI_ATOMICS branch already handles this via its retry
loop; NO_ATOMICS just reloads under the lock.
Two fixes Copilot flagged on #15947:
1. Use __atomic_load_n to re-read last_millis under the lock, not a
plain read. The forward-progression branch (else if) writes
last_millis with __atomic_store_n without holding lock, so the
under-lock plain read would race with it and be UB in the C++
memory model.
2. Reload major from millis_major after acquiring the lock. The
unlocked load at the top of the function can be stale by the time
we get the lock: another thread may have completed a rollover
between the unlocked load and the lock acquisition, leaving our
local major behind by one. Without reload the function could
return a 64-bit timestamp that jumps backwards by ~2^32 ms (~49.7
days). The MULTI_ATOMICS branch already handles this via its retry
loop; NO_ATOMICS just reloads under the lock.
Switch writer-side plain stores under lock_ to __atomic_store_n with
__ATOMIC_RELAXED on NO_ATOMICS. The input value for RMW is read
plainly (safe — only writers mutate, serialised by the lock; readers
only atomic-load so two reads don't race). Closes the formal C++
memory-model hole where plain-store vs atomic-load was a data race
in the standard even though aligned 32-bit STR/LDR on ARMv5TE is
atomic in practice.
Applies to scheduler.h counter mutators and the under-lock writes
to last_millis / millis_major in time_64.cpp's near-rollover branch.
Same ARMv5TE codegen (plain STR). ATOMICS / SINGLE paths unchanged.
Use `#if defined(X)` / `#elif defined(Y)` / `#else` for the three-way
ATOMICS / NO_ATOMICS / SINGLE split. Also fix the SINGLE-branch body
indentation to match the other branches.
The preceding commit needlessly rewrote comments that were still
accurate. Revert the prose-only changes; keep only the two line-level
code changes (__atomic_load_n on the unlocked reads, __atomic_store_n
on the unlocked write).
Same treatment as the scheduler counters (#15947):
- Unlocked reads of millis_major / last_millis at the top of
Millis64Impl::compute(): switch from plain reads to
__atomic_load_n(&..., __ATOMIC_RELAXED).
- Unlocked write of last_millis in the "normal forward progression"
branch: switch from plain assignment to __atomic_store_n(...,
__ATOMIC_RELAXED). This is the one write that happens without the
lock, so it needs to be formally atomic to pair cleanly with the
unlocked atomic reader in the C++ memory model.
- Writes under `lock` stay plain (millis_major++, last_millis = now
inside the near-rollover branch). The lock serialises them against
other writers.
On ARMv5TE the builtins compile to plain LDR/STR — same codegen, no
libatomic dependency. Updates the "accepting minor races" comment to
describe the formally-defined version of the race.
Walk back the __atomic_store_n on the writer paths — the mutators
already hold lock_, so plain counter_++/=/+=/-- is sufficient to
serialise against other writers. The reader fast-path still uses
__atomic_load_n(&counter, __ATOMIC_RELAXED) to express concurrent-
read intent in the C++ memory model and keep the compiler from
caching/eliding the read. On ARMv5TE it compiles to a plain LDR —
same codegen as before.
Copilot review on #15947 flagged that `volatile uint32_t` is not a
well-defined concurrent access in the C++ memory model — it prevents
the compiler caching/eliding the read, but does not turn a plain
cross-thread read/write pair into a defined access. Technically still
a formal data race even though aligned 32-bit LDR/STR on ARMv5TE is
atomic at the hardware level.
Switch the NO_ATOMICS counter reads and writes to GCC's atomic
builtins with __ATOMIC_RELAXED:
- Readers: __atomic_load_n(&counter, __ATOMIC_RELAXED)
- Writers (under lock_): __atomic_store_n(&counter, new_value,
__ATOMIC_RELAXED)
- Increment/decrement (under lock_): explicit load + compute +
__atomic_store_n. Lock_ serialises the load-modify-store against
other writers; the atomic ops make the write visible to concurrent
readers in the memory model.
On ARMv5TE these builtins compile to plain LDR/STR — same codegen as
the previous volatile approach, and no libatomic dependency (only RMW
builtins like __atomic_fetch_add would need the lib). ATOMICS and
SINGLE paths are unchanged.
Rename the seven counter RMW mutators to carry the `_locked_` suffix
that matches the existing convention (pop_raw_locked_,
is_item_removed_locked_, cancel_item_locked_, etc.):
to_add_count_increment_ -> to_add_count_increment_locked_
to_add_count_clear_ -> to_add_count_clear_locked_
defer_count_increment_ -> defer_count_increment_locked_
defer_count_clear_ -> defer_count_clear_locked_
to_remove_add_ -> to_remove_add_locked_
to_remove_decrement_ -> to_remove_decrement_locked_
to_remove_clear_ -> to_remove_clear_locked_
The caller-must-hold-lock contract became load-bearing when the
underlying counters became volatile on NO_ATOMICS: ++/+=/-- compile to
a three-instruction LDR/OP/STR sequence that is not atomic against a
concurrent RMW from another task, so the lock is what keeps the
counter consistent. The new suffix makes the requirement explicit at
every call site, matching how the rest of the scheduler documents the
same invariant.
No behavioural change; all call sites already hold lock_.
The _empty_() helpers (to_add_empty_, defer_empty_, to_remove_empty_)
forced the lock path on ESPHOME_THREAD_MULTI_NO_ATOMICS by hardcoding
`return false`. That made Scheduler::call() pay a FreeRTOS mutex
round-trip for each of process_defer_queue_ / process_to_add /
cleanup_ on every idle tick just to confirm "nothing to do".
On the only NO_ATOMICS target (BK72xx — ARMv5TE, single-core), an
aligned 32-bit load is atomic at the hardware level. Mark the three
skip-work counters volatile so the compiler cannot cache or elide the
read, and let _empty_() compare against zero directly. Writers still
hold lock_ for any RMW — that invariant is unchanged.
A stale 0 is benign: the counter is checked on every Scheduler::call()
iteration, so a missed update is caught next tick. Same pattern as the
NO_ATOMICS reads in time_64.cpp.
On BK72xx at ~3100 iter/min with ~8us/mutex this reclaims roughly
75ms/min of main-loop overhead. Measured on BK7238/BK7231N while
profiling alongside libretiny-eu/libretiny#360.
ATOMICS and SINGLE paths are unchanged (SINGLE keeps plain uint32_t,
no volatile-read overhead).
yield_with_select_ was a trivial one-line passthrough to
esphome::internal::wakeable_delay(). Remove the wrapper and call
wakeable_delay() directly at the two call sites.
socket.h is unused after #15931 moved the host wake mechanism to
wake.cpp. lwip_fast_select.h is already included via application.h
under the same guard.
BK72xx silicon requires a ~200us busy-wait (sctrl_dpll_delay200us) between
two watchdog register key writes on every reload, making each
arch_feed_wdt() call ~300us on BK7231T/N/BK7238. This is hardware errata
in the BDK's wdt_ctrl (beken378/driver/wdt/wdt.c:WCMD_RELOAD_PERIOD) and
cannot be worked around at the SDK level.
LibreTiny initialises the BK72xx HW watchdog at 10000ms, but ESPHome's
generic WDT_FEED_INTERVAL_MS of 300ms was sized for ESP8266's 1.6s soft
watchdog. That left BK72xx over-servicing the watchdog ~33x per timeout
window, paying ~60ms/min of main-loop overhead to no benefit.
Raise the interval to 2000ms on BK72xx only, which keeps a 5x safety
margin on the 10s HW WDT — matching the ESP8266 ratio that originally
motivated the 300ms value — while cutting feed frequency ~6x.
Measured on a NiceMCU XH-WB3S (BK7238) while testing
libretiny-eu/libretiny#360:
Before (300ms interval):
wdt_slow_path: hits=195 (5.9% of iters, avg=315.97us/hit)
main_loop_before_breakdown: sched=73.2ms, wdt=61.6ms, residual=0.0ms
Other platforms retain the existing 300ms value.
BDK 3.0.78 (required by LibreTiny for BK7238 support, see
libretiny-eu/libretiny#360) declares wifi_event_sta_disconnected_t in
wlan_defs_pub.h, which collides with the identically named typedef in
LibreTiny's Arduino WiFi API (WiFiEvents.h). Rename the BDK version
across the include so both headers can coexist. ESPHome only uses
bk_wlan_get_link_status from this header and doesn't reference the
renamed type.
The rename is a no-op on BDK 3.0.33 (BK7231T/N) since that version
doesn't declare the typedef.