[web_server_idf] Reduce loopTask stack usage for state SSE (#21109)

Co-authored-by: Jeroen Jansen <jeroen@Jeroens-MacBook-Air.local>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Jeroen
2026-10-21 16:17:49 +13:00
committed by Jesse Hills
co-authored by Jeroen Jansen J. Nick Koston
parent 9d296a5748
commit 4fdce9d96b
2 changed files with 80 additions and 49 deletions
@@ -57,7 +57,7 @@ namespace esphome::web_server_idf {
static const char *const TAG = "web_server_idf";
// Only send_json_() may hold the JSON arena: every other frame in this file is capped below one
// Only prepare_json_() may hold the JSON arena: every other frame in this file is capped below one
// arena. Measured at -Os on GCC 14; newer toolchains stay checked on purpose, older ones skip it.
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic error "-Wstack-usage=2048"
@@ -1121,68 +1121,88 @@ void AsyncEventSourceResponse::loop() {
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the arena and the JSON buffer
#pragma GCC diagnostic ignored "-Wstack-usage=" // the one frame that holds the JSON arena
#endif
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
// The arena lives only in this frame, so no call chain ever holds two of them
AsyncEventSourceResponse::JsonPrepareResult AsyncEventSourceResponse::prepare_json_(void *source,
message_generator_t *generator,
char *buffer, size_t *size_out) {
// Log lines emitted while the generator runs are dropped for this session: on the large path they would fill
// tail_ before it is overwritten, and on the small path they would go out ahead of the event being built.
SendGuard guard{*this};
json::JsonArena<json::JSON_ARENA_SIZE> arena;
json::JsonBuilder builder(&arena);
generator(this->web_server_, source, builder);
char buf[JSON_BUF_SIZE];
const size_t len = builder.serialize_to(buf, sizeof(buf));
if (len < sizeof(buf)) {
return this->try_send_nodefer(buf, len, "state");
*size_out = builder.serialize_to(buffer, JSON_BUF_SIZE);
if (*size_out < JSON_BUF_SIZE) {
return JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER;
}
// Too large for the stack: the tail holds the whole chunk and loop() drains it. Serialized
// JSON has no raw line break, so the body is one data line.
if (!this->ready_to_send_()) {
return false;
}
{
SendGuard guard{*this};
char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
char prefix[PREFIX_BUF_SIZE];
const size_t prefix_len = build_chunk_prefix(prefix, sizeof(prefix), "state", 0, 0, true);
// Grow the tail until the document fits. Nothing has reached the wire, so a document that
// cannot be held costs only this event, and the tail grown for it is released.
size_t json_len = 0;
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
for (;;) {
if (!this->reserve_tail_(cap)) {
this->tail_alloc_failed_(cap);
return false; // stays deferred, retried on a later pass
}
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
if (json_len < room) {
break;
}
if (cap >= TAIL_MAX_SIZE) {
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", room);
this->tail_.reset();
this->tail_cap_ = 0;
this->send_failure_started_ms_ = 0;
return true; // would never fit, reported as sent
}
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
// Nothing reaches the wire until the complete document and its framing are owned by the caller.
size_t json_len = 0;
size_t cap = std::max<size_t>(JSON_BUF_SIZE * 2, this->tail_cap_);
for (;;) {
if (!this->reserve_tail_(cap)) {
*size_out = cap;
return JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY;
}
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
uint8_t *dst = this->tail_.get();
std::memcpy(dst, prefix, prefix_len);
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
this->tail_len_ = total;
this->tail_sent_ = 0;
const size_t room = cap - prefix_len - SSE_SUFFIX_LEN;
json_len = builder.serialize_to(reinterpret_cast<char *>(this->tail_.get()) + prefix_len, room);
if (json_len < room) {
break;
}
if (cap >= TAIL_MAX_SIZE) {
*size_out = room;
return JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE;
}
cap = std::min<size_t>(cap * 2, TAIL_MAX_SIZE);
}
drain_tail_();
return true;
const size_t total = prefix_len + json_len + SSE_SUFFIX_LEN;
write_chunk_header(prefix, total - CHUNK_HDR_LEN - CHUNK_END_LEN);
uint8_t *dst = this->tail_.get();
std::memcpy(dst, prefix, prefix_len);
std::memcpy(dst + prefix_len + json_len, SSE_SUFFIX, SSE_SUFFIX_LEN);
this->tail_len_ = total;
this->tail_sent_ = 0;
return JsonPrepareResult::JSON_PREPARE_RESULT_TAIL;
}
#if defined(__GNUC__) && !defined(__clang__) && __GNUC__ >= 14 && defined(__OPTIMIZE_SIZE__)
#pragma GCC diagnostic pop
#endif
bool AsyncEventSourceResponse::send_json_(void *source, message_generator_t *generator) {
if (!this->ready_to_send_()) {
return false;
}
char buffer[JSON_BUF_SIZE];
size_t size;
const auto result = this->prepare_json_(source, generator, buffer, &size);
switch (result) {
case JsonPrepareResult::JSON_PREPARE_RESULT_BUFFER:
return this->try_send_nodefer(buffer, size, "state");
case JsonPrepareResult::JSON_PREPARE_RESULT_TAIL:
this->drain_tail_();
return true;
case JsonPrepareResult::JSON_PREPARE_RESULT_NO_MEMORY: {
SendGuard guard{*this};
this->tail_alloc_failed_(size);
return false;
}
case JsonPrepareResult::JSON_PREPARE_RESULT_TOO_LARGE: {
SendGuard guard{*this};
ESP_LOGW(TAG, "State event does not fit %zu bytes, dropped", size);
this->tail_.reset();
this->tail_cap_ = 0;
this->send_failure_started_ms_ = 0;
return true;
}
}
return false;
}
void AsyncEventSourceResponse::tail_alloc_failed_(size_t cap) {
// Same stall clock as a socket that stops draining, so a session cannot retry forever
const uint32_t now = App.get_loop_component_start_time();
@@ -317,6 +317,17 @@ class AsyncEventSourceResponse {
size_t sent);
// Send a state event; JSON too large for the stack buffer is serialized into tail_ instead
bool send_json_(void *source, message_generator_t *generator);
enum class JsonPrepareResult : uint8_t {
JSON_PREPARE_RESULT_BUFFER,
JSON_PREPARE_RESULT_TAIL,
JSON_PREPARE_RESULT_NO_MEMORY,
JSON_PREPARE_RESULT_TOO_LARGE,
};
// Keep the arena out of the caller's frame so it is gone before the state send.
// size_out: BUFFER = JSON length, NO_MEMORY = requested tail capacity, TOO_LARGE = available JSON room;
// unused for TAIL.
JsonPrepareResult __attribute__((noinline))
prepare_json_(void *source, message_generator_t *generator, char *buffer, size_t *size_out);
// Warn once, and close the session once the stall timeout passes with no memory for the tail
void tail_alloc_failed_(size_t cap);
void request_close_();