possible fix for #3201

This commit is contained in:
proddy
2026-09-14 15:48:29 +02:00
parent 39e1555a9e
commit 7ae9d87a72
8 changed files with 116 additions and 28 deletions
+6
View File
@@ -16,6 +16,7 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
- cleaner MD5 and firmware uploading in WebUI [#3222](https://github.com/emsesp/EMS-ESP32/issues/3222) - cleaner MD5 and firmware uploading in WebUI [#3222](https://github.com/emsesp/EMS-ESP32/issues/3222)
- pc0Flow in l/min [#3224](https://github.com/emsesp/EMS-ESP32/issues/3224) - pc0Flow in l/min [#3224](https://github.com/emsesp/EMS-ESP32/issues/3224)
- remove HA config for excluded entities [#3229](https://github.com/emsesp/EMS-ESP32/issues/3229) - remove HA config for excluded entities [#3229](https://github.com/emsesp/EMS-ESP32/issues/3229)
- `show system` reports the Ethernet status on boards with a PHY, so a failed initialisation or a link that never came up is visible instead of Ethernet being omitted completely. Also shows the loop task's lowest free stack, to help diagnose reboots
## Fixed ## Fixed
@@ -28,6 +29,9 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready - HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181) - hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
- solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223) - solar module switches [#3223](https://github.com/emsesp/EMS-ESP32/issues/3223)
- possible crash when the WebUI asked for version information while the hourly versions.json refresh was rewriting the cache from the main loop task
- HTTP client could write past a fixed 64 byte buffer when a URL had a hostname of 64 characters or more, could busy-spin for the whole read budget if the socket reported bytes it wouldn't return, and buffered responses without any size limit
- the "Ethernet clock mode (GPIO16/17) conflicts with PSRAM" error was never logged because Ethernet had already been skipped by the time the check ran
## Changed ## Changed
@@ -42,4 +46,6 @@ This release is based on the latest Espressif/Arduino core version 3. It brings
- set `None` for undefined values in HA `val_tpl` - set `None` for undefined values in HA `val_tpl`
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195) - changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213) - multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
- TLS buffers (~17KB per HTTPS request) are taken from PSRAM on boards that have it, instead of fragmenting the internal heap
- failed versions.json fetches back off from 5 up to 160 minutes instead of retrying every 5 minutes, as each attempt blocks the main loop for as long as the DNS, connect and read timeouts allow
+3
View File
@@ -43,6 +43,9 @@ build_flags =
; ESPAsyncWebServer ; ESPAsyncWebServer
; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8 ; -D WS_MAX_QUEUED_MESSAGES=0 ; not used, default 8
; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32 ; -D SSE_MAX_QUEUED_MESSAGES=1 ; for log messages, default 32
; ESP_SSLClient
-D ENABLE_PSRAM ; take the ~17KB of TLS buffers from PSRAM instead of the internal heap. The library
; needs BOARD_HAS_PSRAM too, so boards without PSRAM keep using the internal heap
-D EMSESP_MQTT_RUNNING_CORE=1 ; default 1 -D EMSESP_MQTT_RUNNING_CORE=1 ; default 1
; -D EMSESP_MQTT_STACKSIZE=5120 ; default ; -D EMSESP_MQTT_STACKSIZE=5120 ; default
-D EMSESP_UART_RUNNING_CORE=1 ; default any core -D EMSESP_UART_RUNNING_CORE=1 ; default any core
+37 -10
View File
@@ -35,6 +35,31 @@ int HttpClient::request(std::string url, const std::string & method, const std::
return 0; // unsupported scheme return 0; // unsupported scheme
} }
// split the host from the path before anything is allocated, so a bad URL costs nothing
url.replace(0, is_https ? 8 : 7, "");
std::string host = url;
auto index = url.find_first_of('/');
if (index != std::string::npos) {
host = url.substr(0, index);
url.replace(0, index, "");
} else {
url = "/";
}
if (host.empty()) {
return 0;
}
// ESP_SSLClient keeps the host in a fixed char[64] and strcpy()s into it without a length
// check, so a longer name would corrupt whatever follows it and crash somewhere unrelated
if (host.length() > MAX_HOSTNAME_LENGTH) {
EMSESP::logger().warning("%s hostname is too long (%u characters, max is %u)",
is_https ? "HTTPS" : "HTTP",
(unsigned)host.length(),
(unsigned)MAX_HOSTNAME_LENGTH);
return 0;
}
WiFiClient * basic_client = new WiFiClient; WiFiClient * basic_client = new WiFiClient;
ESP_SSLClient * ssl_client = new ESP_SSLClient; ESP_SSLClient * ssl_client = new ESP_SSLClient;
if (is_https) { if (is_https) {
@@ -50,16 +75,6 @@ int HttpClient::request(std::string url, const std::string & method, const std::
ssl_client->setTimeout(5); // seconds, drives BearSSL only - unused on the plain HTTP path ssl_client->setTimeout(5); // seconds, drives BearSSL only - unused on the plain HTTP path
ssl_client->setClient(basic_client, is_https); // enableSSL = false for plain HTTP ssl_client->setClient(basic_client, is_https); // enableSSL = false for plain HTTP
url.replace(0, is_https ? 8 : 7, "");
std::string host = url;
auto index = url.find_first_of('/');
if (index != std::string::npos) {
host = url.substr(0, index);
url.replace(0, index, "");
} else {
url = "/";
}
const uint16_t port = is_https ? 443 : 80; const uint16_t port = is_https ? 443 : 80;
if (ssl_client->connect(host.c_str(), port)) { if (ssl_client->connect(host.c_str(), port)) {
bool content_set = false; bool content_set = false;
@@ -97,16 +112,25 @@ int HttpClient::request(std::string url, const std::string & method, const std::
// the stream goes idle, or the overall budget runs out // the stream goes idle, or the overall budget runs out
const uint32_t started = millis(); const uint32_t started = millis();
uint32_t last_data = started; uint32_t last_data = started;
bool truncated = false;
while (millis() - started < TOTAL_TIMEOUT_MS) { while (millis() - started < TOTAL_TIMEOUT_MS) {
const int avail = ssl_client->available(); const int avail = ssl_client->available();
if (avail > 0) { if (avail > 0) {
if (result.length() >= MAX_RESPONSE_BYTES) {
truncated = true;
break;
}
uint8_t buf[128]; uint8_t buf[128];
const size_t want = (avail < (int)sizeof(buf)) ? (size_t)avail : sizeof(buf); const size_t want = (avail < (int)sizeof(buf)) ? (size_t)avail : sizeof(buf);
const int len = ssl_client->read(buf, want); const int len = ssl_client->read(buf, want);
if (len > 0) { if (len > 0) {
result.append(reinterpret_cast<const char *>(buf), len); result.append(reinterpret_cast<const char *>(buf), len);
last_data = millis(); last_data = millis();
continue;
} }
// available() reported bytes the client won't hand over. Yielding here keeps a
// stale count from busy-spinning the whole read budget on this task's core
delay(1);
continue; continue;
} }
if (!ssl_client->connected()) { if (!ssl_client->connected()) {
@@ -121,6 +145,9 @@ int HttpClient::request(std::string url, const std::string & method, const std::
ssl_client->stop(); ssl_client->stop();
const auto received = result.length(); const auto received = result.length();
if (truncated) {
EMSESP::logger().warning("%s response from %s truncated at %u bytes", is_https ? "HTTPS" : "HTTP", host.c_str(), (unsigned)received);
}
// parse the status line "HTTP/1.x <code> <reason>". stoi() would abort rather than throw on // parse the status line "HTTP/1.x <code> <reason>". stoi() would abort rather than throw on
// a malformed response, since the firmware is built with -fno-exceptions // a malformed response, since the firmware is built with -fno-exceptions
+6
View File
@@ -34,6 +34,12 @@ class HttpClient {
static constexpr uint32_t FIRST_BYTE_TIMEOUT_MS = 8000; // how long the server may take to start replying static constexpr uint32_t FIRST_BYTE_TIMEOUT_MS = 8000; // how long the server may take to start replying
static constexpr uint32_t IDLE_TIMEOUT_MS = 500; // gap in the stream that marks the end of a response static constexpr uint32_t IDLE_TIMEOUT_MS = 500; // gap in the stream that marks the end of a response
static constexpr uint32_t TOTAL_TIMEOUT_MS = 10000; // ceiling for the whole read static constexpr uint32_t TOTAL_TIMEOUT_MS = 10000; // ceiling for the whole read
// ceiling on the response we'll buffer. Without it a chatty or hijacked endpoint can grow the
// result string until the heap runs out, which aborts the firmware since it's built -fno-exceptions
static constexpr size_t MAX_RESPONSE_BYTES = 32 * 1024;
static constexpr size_t MAX_HOSTNAME_LENGTH = 63; // ESP_SSLClient stores the host in a char[64]
}; };
} // namespace emsesp } // namespace emsesp
+6
View File
@@ -1126,6 +1126,12 @@ void System::show_system(uuid::console::Shell & shell) {
shell.printfln(" Internal heap free/largest block: %u KB / %u KB", shell.printfln(" Internal heap free/largest block: %u KB / %u KB",
heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) / 1024, heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) / 1024,
heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) / 1024); heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) / 1024);
// Lowest the loop task's stack has ever been since boot. EMSESP::loop() runs the blocking HTTP
// client and all the JSON work on this task, and a TLS handshake alone can want several KB
TaskHandle_t loop_task = xTaskGetHandle("loopTask");
if (loop_task != nullptr) {
shell.printfln(" Loop task stack min free/total: %u / %u bytes", (unsigned)uxTaskGetStackHighWaterMark(loop_task), (unsigned)getArduinoLoopTaskStackSize());
}
#endif #endif
shell.printfln(" App used/free: %lu KB / %lu KB", appUsed(), appFree()); shell.printfln(" App used/free: %lu KB / %lu KB", appUsed(), appFree());
uint32_t FSused = LittleFS.usedBytes() / 1024; uint32_t FSused = LittleFS.usedBytes() / 1024;
+1 -1
View File
@@ -1 +1 @@
#define EMSESP_APP_VERSION "3.9.0-dev.9" #define EMSESP_APP_VERSION "3.9.0-dev.10"
+44 -16
View File
@@ -348,7 +348,18 @@ void WebStatusService::getVersions(JsonObject root) {
} }
} }
if (!versions_cache_valid_) { // take a snapshot, since the loop task can be rewriting the cache while we run on AsyncTCP
VersionInfo stable;
VersionInfo dev;
bool cache_valid;
{
std::lock_guard<std::mutex> lock{versions_mutex_};
cache_valid = versions_cache_valid_;
stable = versions_stable_;
dev = versions_dev_;
}
if (!cache_valid) {
// no successful fetch yet (no network, fetch pending, or parse error) // no successful fetch yet (no network, fetch pending, or parse error)
return; return;
} }
@@ -364,8 +375,8 @@ void WebStatusService::getVersions(JsonObject root) {
out["upgradeable"] = info.upgradeable; out["upgradeable"] = info.upgradeable;
}; };
add_section("stable", versions_stable_); add_section("stable", stable);
add_section("dev", versions_dev_); add_section("dev", dev);
#else #else
// standalone/test build: provide deterministic dummy data // standalone/test build: provide deterministic dummy data
JsonObject stable_out = root["stable"].to<JsonObject>(); JsonObject stable_out = root["stable"].to<JsonObject>();
@@ -383,7 +394,8 @@ void WebStatusService::getVersions(JsonObject root) {
// schedule the next versions.json fetch a few seconds out so the network stack has time to settle // schedule the next versions.json fetch a few seconds out so the network stack has time to settle
// (DHCP completion, default-netif assignment and DNS server propagation through lwip) // (DHCP completion, default-netif assignment and DNS server propagation through lwip)
void WebStatusService::schedule_versions_refresh() { void WebStatusService::schedule_versions_refresh() {
uint32_t next = uuid::get_uptime() + VERSIONS_INITIAL_FETCH_DELAY_MS; versions_failures_ = 0; // a fresh link is a fresh chance, so start again from the short retry delay
uint32_t next = uuid::get_uptime() + VERSIONS_INITIAL_FETCH_DELAY_MS;
if (next == 0) { if (next == 0) {
next = 1; // 0 is the "idle" sentinel — never let the wrap land there next = 1; // 0 is the "idle" sentinel — never let the wrap land there
} }
@@ -409,8 +421,18 @@ void WebStatusService::loop() {
return; return;
} }
bool ok = refresh_versions_cache(); uint32_t interval;
uint32_t next = uuid::get_uptime() + (ok ? VERSIONS_REFRESH_INTERVAL_MS : VERSIONS_RETRY_INTERVAL_MS); if (refresh_versions_cache()) {
versions_failures_ = 0;
interval = VERSIONS_REFRESH_INTERVAL_MS;
} else {
if (versions_failures_ < VERSIONS_MAX_BACKOFF_STEPS) {
versions_failures_++;
}
interval = VERSIONS_RETRY_INTERVAL_MS << (versions_failures_ - 1);
}
uint32_t next = uuid::get_uptime() + interval;
if (next == 0) { if (next == 0) {
next = 1; next = 1;
} }
@@ -451,16 +473,21 @@ bool WebStatusService::refresh_versions_cache() {
out.upgradeable = !out.version.empty() && FirmwareVersion(out.version) > current_version; out.upgradeable = !out.version.empty() && FirmwareVersion(out.version) > current_version;
}; };
read_section("stable", versions_stable_); // parse into locals first, then publish in one go so the AsyncTCP task never sees a half-written cache
read_section("dev", versions_dev_); VersionInfo stable;
VersionInfo dev;
read_section("stable", stable);
read_section("dev", dev);
{
std::lock_guard<std::mutex> lock{versions_mutex_};
versions_stable_ = stable;
versions_dev_ = dev;
versions_cache_valid_ = true;
}
versions_cache_valid_ = true;
#if defined(EMSESP_DEBUG) #if defined(EMSESP_DEBUG)
EMSESP::logger().debug("Fetched from %s: stable=%s, dev=%s, current=%s", EMSESP::logger().debug("Fetched from %s: stable=%s, dev=%s, current=%s", VERSIONS_URL, stable.version.c_str(), dev.version.c_str(), current_version_s.c_str());
VERSIONS_URL,
versions_stable_.version.c_str(),
versions_dev_.version.c_str(),
current_version_s.c_str());
#endif #endif
return true; return true;
#endif #endif
@@ -468,11 +495,12 @@ bool WebStatusService::refresh_versions_cache() {
// returns if current dev/stable is upgradeable // returns if current dev/stable is upgradeable
bool WebStatusService::current_upgradeable() const { bool WebStatusService::current_upgradeable() const {
FirmwareVersion current_version(current_version_s);
bool is_dev = current_version.prerelease().find("dev") != std::string::npos;
std::lock_guard<std::mutex> lock{versions_mutex_};
if (!versions_cache_valid_) { if (!versions_cache_valid_) {
return false; return false;
} }
FirmwareVersion current_version(current_version_s);
bool is_dev = current_version.prerelease().find("dev") != std::string::npos;
return is_dev ? versions_dev_.upgradeable : versions_stable_.upgradeable; return is_dev ? versions_dev_.upgradeable : versions_stable_.upgradeable;
} }
+13 -1
View File
@@ -7,6 +7,8 @@
#include "../core/firmwareVersion.h" #include "../core/firmwareVersion.h"
#include "../emsesp_version.h" #include "../emsesp_version.h"
#include <mutex>
namespace emsesp { namespace emsesp {
class WebStatusService { class WebStatusService {
@@ -67,14 +69,24 @@ class WebStatusService {
VersionInfo versions_dev_; VersionInfo versions_dev_;
bool versions_cache_valid_ = false; // true once we've had at least one successful fetch bool versions_cache_valid_ = false; // true once we've had at least one successful fetch
uint32_t versions_next_fetch_ms_ = 0; // uuid::get_uptime() of the next attempt; 0 = idle uint32_t versions_next_fetch_ms_ = 0; // uuid::get_uptime() of the next attempt; 0 = idle
uint8_t versions_failures_ = 0; // consecutive failed fetches, drives the retry backoff
// the cache is written by the loop task in refresh_versions_cache() and read by the AsyncTCP
// task in getVersions()/current_upgradeable(), so the std::strings need guarding - without it a
// request landing mid-update reads a buffer that has just been freed
mutable std::mutex versions_mutex_;
bool refresh_versions_cache(); // does the actual HTTPS fetch + parse, returns true on success bool refresh_versions_cache(); // does the actual HTTPS fetch + parse, returns true on success
static constexpr const char * VERSIONS_URL = "http://emsesp.org/versions.json"; static constexpr const char * VERSIONS_URL = "http://emsesp.org/versions.json";
static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success
static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after failure static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after the first failure
static constexpr uint32_t VERSIONS_INITIAL_FETCH_DELAY_MS = 5UL * 1000UL; // 5 s after a link comes up static constexpr uint32_t VERSIONS_INITIAL_FETCH_DELAY_MS = 5UL * 1000UL; // 5 s after a link comes up
// each attempt blocks the loop task for as long as the DNS lookup, connect and read timeouts
// allow, so double the retry delay per consecutive failure: 5, 10, 20, 40, 80, 160 minutes
static constexpr uint8_t VERSIONS_MAX_BACKOFF_STEPS = 6;
}; };
} // namespace emsesp } // namespace emsesp