diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index c00b07684be..ef0d5841add 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -129,10 +129,13 @@ bool renameFile(const char *pathFrom, const char *pathTo) #endif } +#include +#include #include -#include -#include #include +#ifdef ARCH_ESP32 +#include +#endif /** * @brief Platform-agnostic filesystem format / wipe. @@ -250,6 +253,12 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec } // namespace #endif +#ifdef ARCH_ESP32 +// Headroom kept below the allocator's largest free block when sizing the manifest: the block reported +// includes the allocator's own bookkeeping, and other tasks keep allocating while the SPI lock is held. +static constexpr size_t FILES_MANIFEST_HEAP_MARGIN = 1024; +#endif + /** * @brief Get the list of files in a directory. * @@ -268,18 +277,41 @@ std::vector getFiles(const char *dirname, uint8_t levels, s if (wasLimited) *wasLimited = false; #ifdef FSCom -#if defined(__cpp_exceptions) || defined(__EXCEPTIONS) - size_t reservedCount = maxCount; + // Size the vector once, up front, to what the heap can actually hand out, and cap the walk at that + // count so push_back() never has to grow it. Any allocation that fails here goes through operator + // new and raises std::bad_alloc; the ESP32 framework is built with CONFIG_COMPILER_CXX_EXCEPTIONS=n, + // so there is no unwinder and a throw is std::terminate() -> abort() -> reboot. That fires on the + // very first client handshake whenever the heap is fragmented (WiFi + TLS up, no PSRAM), which is + // exactly when this runs. So: never let reserve() be the thing that discovers there is no room. + // Cap at what a vector of FileInfo can hold at all: it keeps the probe's byte count from wrapping + // for a huge maxCount, and it is also the bound reserve() would otherwise reject with a throw. + size_t reservedCount = std::min(maxCount, filenames.max_size()); +#ifdef ARCH_ESP32 + // Ask the allocator for the largest contiguous block malloc() could hand out. MALLOC_CAP_DEFAULT + // is the capability heap_caps_malloc_default() (what operator new resolves to) falls back to + // across every region, internal and PSRAM alike, so this is the "will new succeed" question + // asked directly. Nothing is freed before the reserve, so there is no hole for another task to + // take between the probe and the allocation. + const size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT); + // Leave a margin below the largest block: the allocator's own overhead sits inside it, and other + // threads keep allocating while we hold the SPI lock. + const size_t usable = largest > FILES_MANIFEST_HEAP_MARGIN ? largest - FILES_MANIFEST_HEAP_MARGIN : 0; + reservedCount = std::min(reservedCount, usable / sizeof(meshtastic_FileInfo)); +#else + // Other targets have no largest-block query. Probe with malloc() - the allocation that returns + // nullptr on failure under every build (new(std::nothrow) is not that: libstdc++ implements it as + // a try/catch around the throwing form) - free the probe, and reserve the size that fit. Not + // airtight against a concurrent allocator, but the SPI lock the caller holds serialises the usual + // competitors and it is strictly better than letting reserve() be the first to find out. while (reservedCount > 0) { - try { - filenames.reserve(reservedCount); + void *probe = malloc(reservedCount * sizeof(meshtastic_FileInfo)); + if (probe) { + free(probe); break; - } catch (const std::bad_alloc &) { - reservedCount /= 2; - } catch (const std::length_error &) { - reservedCount /= 2; } + reservedCount /= 2; } +#endif if (reservedCount == 0) { if (wasLimited) *wasLimited = true; @@ -290,7 +322,7 @@ std::vector getFiles(const char *dirname, uint8_t levels, s *wasLimited = true; maxCount = reservedCount; } -#endif + filenames.reserve(reservedCount); collectFiles(dirname, levels, maxCount, filenames, wasLimited); #endif return filenames; diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 813d413dea4..b45783677eb 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -325,10 +325,10 @@ void PhoneAPI::handleStartConfig() filesManifest = getFiles("/", FILES_MANIFEST_LEVELS, FILES_MANIFEST_MAX_COUNT, &filesManifestLimited); } if (filesManifestLimited) { - LOG_WARN("Got %zu files in manifest (limited to %zu entries/depth %u)", filesManifest.size(), - FILES_MANIFEST_MAX_COUNT, static_cast(FILES_MANIFEST_LEVELS)); + LOG_WARN("Got %u files in manifest (limited to %u entries/depth %u)", (unsigned)filesManifest.size(), + (unsigned)FILES_MANIFEST_MAX_COUNT, static_cast(FILES_MANIFEST_LEVELS)); } else { - LOG_DEBUG("Got %zu files in manifest", filesManifest.size()); + LOG_DEBUG("Got %u files in manifest", (unsigned)filesManifest.size()); } } else { releaseFilesManifest(filesManifest); diff --git a/src/mesh/api/ServerAPI.cpp b/src/mesh/api/ServerAPI.cpp index 20ff8af9995..7303ae3044e 100644 --- a/src/mesh/api/ServerAPI.cpp +++ b/src/mesh/api/ServerAPI.cpp @@ -5,6 +5,8 @@ #include "ServerAPI.h" #include "Throttle.h" #include +#include +#include static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL; @@ -117,7 +119,22 @@ template int32_t APIServerPort::runOnce() openAPI.reset(); } - openAPI.reset(new T(client)); + // A ServerAPI carries the stream rx/tx buffers plus the FromRadio/ToRadio scratch, several + // KB in one block. On ESP32 a new that cannot get that block is a reboot (see the note on + // openAPI in the header), and std::nothrow does not help there because libstdc++ builds it + // on the throwing form. malloc() does return nullptr, so take the block from malloc() and + // construct in place; if there is no room drop this connection instead of the node - the + // client retries and the next accept gets a fresh look at the heap. The T constructors do + // not allocate (default-constructed containers, fixed-size thread table), so nothing inside + // the placement new can throw either. + void *block = malloc(sizeof(T)); + if (!block) { + LOG_ERROR("No heap for API connection (%u bytes), dropping client", (unsigned)sizeof(T)); + client.stop(); + } else { + openAPI.reset(new (block) T(client)); + } + // cppcheck-suppress memleak ; block is owned by openAPI via placement new, freed by MallocDeleter } #if RAK_4631 diff --git a/src/mesh/api/ServerAPI.h b/src/mesh/api/ServerAPI.h index ece8e0ba234..05c3bb56f50 100644 --- a/src/mesh/api/ServerAPI.h +++ b/src/mesh/api/ServerAPI.h @@ -1,6 +1,7 @@ #pragma once #include "StreamAPI.h" +#include #include #define SERVER_API_DEFAULT_PORT 4403 @@ -44,8 +45,23 @@ template class APIServerPort : public U, private concurrency: * * FIXME: We currently only allow one open TCP connection at a time, because we depend on the loop() call in this class to * delegate to the worker. Once coroutines are implemented we can relax this restriction. + * + * The ServerAPI is built in a malloc()'d block with placement new rather than operator new: on ESP32 the framework + * is compiled with CONFIG_COMPILER_CXX_EXCEPTIONS=n and every throw is wrapped to abort(), which makes a failed + * operator new - the plain form and, because libstdc++ implements it as a try/catch around the plain form, the + * std::nothrow form too - a reboot. malloc() is the one allocation on that platform that hands back nullptr, so + * a fragmented heap drops the incoming client instead of the node. The deleter runs the destructor and free()s. */ - std::unique_ptr openAPI; + struct MallocDeleter { + void operator()(T *p) const + { + if (p) { + p->~T(); + free(p); + } + } + }; + std::unique_ptr openAPI; #if defined(RAK_4631) || defined(RAK11310) // Track wait time for RAK13800 Ethernet requests int32_t waitTime = 100;