From ce577018c27def965f48233edadb5114f45847dd Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 6 Jul 2026 08:03:48 -0500 Subject: [PATCH 1/4] Right-size nRF52 heap tiers after 2.8.0 heap-exhaustion field reports Field reports on 2.8.0 show nRF52840 devices at 99% heap (114/115 KB) within minutes of boot; operator new asserts on OOM, so these devices are one allocation from a reboot. The 2.8.0 cache sizing ladders gave nRF52 the largest non-PSRAM tiers on the assumption that a BLE-only part has a roomy heap - the arena is actually ~125 KB shared with the FreeRTOS task stacks. Per-target retiers (nRF52840 unless noted): - Traffic Management cache 1000 -> 250 entries (10 KB -> 2.5 KB); the unclassified fallthrough drops 1000 -> 400 to match the classic-ESP32 tier (also affects RP2040/RP2350) - Warm node store 200 -> 100 entries (8 KB -> 4 KB); the non-XXAA fallthrough drops 320 -> 100 so an unclassified RAM-constrained part can't boot-allocate 12.8 KB - MESSAGE_HISTORY_LIMIT 20 -> 10 (text pool 4.4 KB -> 2.2 KB), the tier classic ESP32 already ships - MAX_RX_TOPHONE 32 -> 16, shrinking the static packet pool 70 -> 54 slots (~6.6 KB of .bss returned to the heap arena) - PacketHistory hash index off arch-wide (1 KB); O(n) over 240 records is negligible at LoRa packet rates - OLEDDISPLAY_REDUCE_MEMORY arch-wide (~1 KB OLED back buffer); the five TFT variants -U it because TFTDisplay.cpp needs buffer_back for dirty-window diffing - Drop the stale "for testing" 1024-entry TMM override on T1000-E Measured on rak4631: heap arena grows 124,572 -> 131,180 B and boot allocations drop ~15.7 KB, roughly +22 KB free heap on the field-report device class. Migration: the nRF52840 warm flash ring replays through place() (LRU), so the newest 100 identities survive the shrink; the file backend rejects oversized snapshots cleanly (new test covers this). Native suites pass (536/536 Docker, 13/13 native-macos warm store); rak4631, heltec-mesh-node-t114 (TFT) and tracker-t1000-e build green. --- src/MessageStore.h | 10 +++-- src/mesh/mesh-pb-constants.h | 33 ++++++++++---- test/test_warm_store/test_main.cpp | 45 +++++++++++++++++++ .../heltec_mesh_node_t096/platformio.ini | 1 + .../heltec_mesh_node_t1/platformio.ini | 1 + .../heltec_mesh_node_t114/platformio.ini | 1 + .../nrf52840/heltec_mesh_solar/platformio.ini | 1 + variants/nrf52840/nrf52.ini | 2 + .../nrf52840/rak_wismeshtap/platformio.ini | 1 + variants/nrf52840/tracker-t1000-e/variant.h | 9 ---- 10 files changed, 82 insertions(+), 22 deletions(-) diff --git a/src/MessageStore.h b/src/MessageStore.h index 89c9f83bfef..04080619755 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -21,10 +21,12 @@ // How many messages are stored (RAM + flash). // Define -DMESSAGE_HISTORY_LIMIT=N in build_flags to control memory usage. #ifndef MESSAGE_HISTORY_LIMIT -#if defined(ARCH_ESP32) && \ - !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32S2)) -// Baseline ESP32 (non-PSRAM variants) has limited heap; reduce message history on resource-constrained builds. -// Override with -DMESSAGE_HISTORY_LIMIT=N if needed. +#if (defined(ARCH_ESP32) && \ + !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32S2))) || \ + defined(NRF52840_XXAA) +// Baseline ESP32 (non-PSRAM variants) and nRF52840 (~115 KB heap arena shared with SoftDevice + +// FreeRTOS stacks; 2.8.0 field reports hit 99% use) have limited heap; reduce message history on +// resource-constrained builds. Override with -DMESSAGE_HISTORY_LIMIT=N if needed. #define MESSAGE_HISTORY_LIMIT 10 #else #define MESSAGE_HISTORY_LIMIT 20 diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index eaaf5dadd44..040bdf732c0 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -17,6 +17,12 @@ #ifndef MAX_RX_TOPHONE #if defined(ARCH_ESP32) && !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3)) #define MAX_RX_TOPHONE 8 +#elif defined(NRF52840_XXAA) +// Each slot is a ~340 B MeshPacket in the static pool (Router.cpp MAX_PACKETS_STATIC), so 32 slots +// cost ~11 KB of .bss on the RAM-tightest platform (2.8.0 field reports: 99% heap). 16 still doubles +// the 8 classic ESP32 has shipped with for years; drops start when a stalled phone/serial client has +// 16 packets queued. +#define MAX_RX_TOPHONE 16 #else #define MAX_RX_TOPHONE 32 #endif @@ -126,7 +132,10 @@ static inline int get_max_num_nodes() // Keyed on the NRF52840_XXAA build flag, not ARCH_NRF52: the latter (from // architecture.h via configuration.h) isn't defined this early in every include // chain. Backed by the raw-flash ring below LittleFS - see WarmNodeStore.h. -#define WARM_NODE_COUNT 200 +// 100 (was 200): the RAM cache is 40 B/entry calloc'd from the ~115 KB heap +// arena, which 2.8.0 field reports showed at 99% use; 120 hot + 100 warm +// identities still covers meshes well past the hot cap. +#define WARM_NODE_COUNT 100 #elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) #define WARM_NODE_COUNT 2000 // PSRAM-equipped ESP32-S3 / native host; warm cache in PSRAM (~80 KB) #elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4) @@ -136,10 +145,11 @@ static inline int get_max_num_nodes() #elif defined(ARCH_RP2040) #define WARM_NODE_COUNT 150 // RP2040 (264 KB) / RP2350 (520 KB): bounded so warm.dat write fits the 8s watchdog (#10746) #else -// nRF52840 is handled explicitly above (200, raw-flash ring). Any other nRF52 (non-XXAA) and any -// future non-ESP32/non-RP LittleFS part fall through to this 320 default - flag for review if such a -// RAM-constrained nRF52 target is ever added. -#define WARM_NODE_COUNT 320 // other LittleFS-backed parts (e.g. non-nRF52840 nRF52) +// nRF52840 is handled explicitly above (raw-flash ring). Any other nRF52 (non-XXAA) and any future +// non-ESP32/non-RP LittleFS part lands here. Reviewed after the 2.8.0 nRF52840 heap-exhaustion +// reports: fail small (match nRF52840) so an unclassified RAM-constrained part can't boot-allocate +// 12.8 KB; RAM-rich targets should opt up explicitly in their variant. +#define WARM_NODE_COUNT 100 // other LittleFS-backed parts (e.g. non-nRF52840 nRF52) #endif // platform #endif // WARM_NODE_COUNT @@ -177,11 +187,16 @@ static inline int get_max_num_nodes() #define TRAFFIC_MANAGEMENT_CACHE_SIZE 500 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~5 KB heap (#10705) #elif defined(ARCH_ESP32) #define TRAFFIC_MANAGEMENT_CACHE_SIZE 400 // classic ESP32 / S2 / C3: tightest free heap, ~4 KB (#10705) +#elif defined(NRF52840_XXAA) +// Keyed on NRF52840_XXAA (not ARCH_NRF52) for the same include-order reason as WARM_NODE_COUNT above. +// The 256 KB part shares its ~115 KB heap arena with SoftDevice and the FreeRTOS task stacks; 2.8.0 +// field reports hit 99% heap use with the old 1000-entry (~10 KB) cache. 250 entries (2.5 KB) still +// tracks >2x the 120-node hot store; LRU victim recycling absorbs busier meshes. +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 250 #else -// nRF52 (incl. nRF52840) and RP2040/RP2350 fall through here - there is no nRF/RP branch above, -// by design. These parts have no ESP32-style WiFi+BLE coexistence eating the heap, so the larger -// 1000-entry (~10 KB) cache fits: nRF52840 is BLE-only on 256 KB RAM; RP2040/RP2350 have 264/520 KB. -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 // nRF52 / RP2040 / RP2350 / other non-ESP32 +// RP2040/RP2350 and anything unclassified land here: match the classic-ESP32 tier rather than +// defaulting large. RAM-rich targets should opt up explicitly in their variant. +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 400 #endif #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE diff --git a/test/test_warm_store/test_main.cpp b/test/test_warm_store/test_main.cpp index 43820b521f4..e26d9ee456d 100644 --- a/test/test_warm_store/test_main.cpp +++ b/test/test_warm_store/test_main.cpp @@ -257,6 +257,50 @@ void test_ws_v1_migration_discardsLastHeard() b.saveIfDirty(); } +// Shrink safety: a warm.dat snapshot recording more entries than this build's +// WARM_NODE_COUNT (e.g. written before a per-platform tier reduction) must be +// rejected cleanly at the header check - load() starts empty instead of reading +// past entries[]. (The nRF52840 raw-flash ring backend has no such cliff: it +// replays records through place(), whose LRU admission keeps the newest.) +void test_ws_load_rejectsOversizedSnapshot() +{ + WarmNodeStore a; + a.absorb(0xA00, 111, NULL); + if (!a.saveIfDirty()) { + TEST_IGNORE_MESSAGE("Filesystem not available in this test environment"); + return; + } + + // Patch the header's count field (offset 8) to one past capacity. + std::vector buf; + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ); + if (!f) { + TEST_IGNORE_MESSAGE("warm.dat not readable in this environment"); + return; + } + buf.resize(f.size()); + f.read(buf.data(), buf.size()); + f.close(); + } + TEST_ASSERT_TRUE(buf.size() >= 16); + const uint16_t oversized = (uint16_t)(WARM_NODE_COUNT + 1); + memcpy(buf.data() + 8, &oversized, sizeof(oversized)); + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + f.write(buf.data(), buf.size()); + f.close(); + } + + WarmNodeStore b; + b.load(); + TEST_ASSERT_EQUAL(0, b.count()); // rejected as invalid, started empty + + b.clear(); + b.saveIfDirty(); +} + WS_TEST_ENTRY void setup() { initializeTestEnvironment(); @@ -273,6 +317,7 @@ WS_TEST_ENTRY void setup() RUN_TEST(test_ws_remove_and_clear); RUN_TEST(test_ws_persistence_roundTrip); RUN_TEST(test_ws_v1_migration_discardsLastHeard); + RUN_TEST(test_ws_load_rejectsOversizedSnapshot); exit(UNITY_END()); } diff --git a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini index e1bdd529d05..03aa033103c 100644 --- a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini @@ -15,6 +15,7 @@ board_level = pr debug_tool = jlink build_flags = ${nrf52840_base.build_flags} + -UOLEDDISPLAY_REDUCE_MEMORY ; TFTDisplay.cpp needs the lib's buffer_back for dirty-window diffing -Ivariants/nrf52840/heltec_mesh_node_t096 -D HAS_LORA_FEM=1 -D HELTEC_MESH_NODE_T096 diff --git a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini index 664d1616e0b..7d822685a21 100644 --- a/variants/nrf52840/heltec_mesh_node_t1/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t1/platformio.ini @@ -15,6 +15,7 @@ board_level = pr debug_tool = jlink build_flags = ${nrf52840_base.build_flags} + -UOLEDDISPLAY_REDUCE_MEMORY ; TFTDisplay.cpp needs the lib's buffer_back for dirty-window diffing -Ivariants/nrf52840/heltec_mesh_node_t1 -DHELTEC_MESH_NODE_T1 -DUSE_TFTDISPLAY diff --git a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini index c9f998240a5..0f872745325 100644 --- a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini @@ -16,6 +16,7 @@ debug_tool = jlink # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. build_flags = ${nrf52840_base.build_flags} + -UOLEDDISPLAY_REDUCE_MEMORY ; TFTDisplay.cpp needs the lib's buffer_back for dirty-window diffing -Ivariants/nrf52840/heltec_mesh_node_t114 -DHELTEC_T114 diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini index e4d3c505845..536045cfac2 100644 --- a/variants/nrf52840/heltec_mesh_solar/platformio.ini +++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini @@ -105,6 +105,7 @@ build_flags = ${heltec_mesh_solar_base.build_flags} [env:heltec-mesh-solar-tft] extends = heltec_mesh_solar_base build_flags = ${heltec_mesh_solar_base.build_flags} + -UOLEDDISPLAY_REDUCE_MEMORY ; TFTDisplay.cpp needs the lib's buffer_back for dirty-window diffing -DHELTEC_MESH_SOLAR_TFT -DSPI_INTERFACES_COUNT=2 -DHAS_SPI_TFT=1 diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index f1502ecf387..6cf4c57cc7a 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -26,6 +26,8 @@ build_flags = -DLFS_NO_ASSERT ; Disable LFS assertions , see https://github.com/meshtastic/firmware/pull/3818 -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 + -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 ; drop PacketHistory's 1 KB hash index; O(n) over ~240 records is negligible at LoRa packet rates + -DOLEDDISPLAY_REDUCE_MEMORY ; drop the ThingPulse OLED back buffer (~1 KB); TFT variants must -U this (TFTDisplay.cpp needs buffer_back) -Os -std=gnu++17 -flto ; whole-image LTO (~-60KB) on every nrf52840 target; nrf52_lto.py (pre: extra_script) keeps the interrupt handlers out of LTO so they survive diff --git a/variants/nrf52840/rak_wismeshtap/platformio.ini b/variants/nrf52840/rak_wismeshtap/platformio.ini index f058d915351..9ccc2b79659 100644 --- a/variants/nrf52840/rak_wismeshtap/platformio.ini +++ b/variants/nrf52840/rak_wismeshtap/platformio.ini @@ -12,6 +12,7 @@ custom_meshtastic_tags = RAK extends = nrf52840_base board = wiscore_rak4631 build_flags = ${nrf52840_base.build_flags} + -UOLEDDISPLAY_REDUCE_MEMORY ; TFTDisplay.cpp needs the lib's buffer_back for dirty-window diffing -Ivariants/nrf52840/rak_wismeshtap -DWISMESH_TAP -DRAK_4631 diff --git a/variants/nrf52840/tracker-t1000-e/variant.h b/variants/nrf52840/tracker-t1000-e/variant.h index de6916ac74c..b064dbc9f24 100644 --- a/variants/nrf52840/tracker-t1000-e/variant.h +++ b/variants/nrf52840/tracker-t1000-e/variant.h @@ -154,15 +154,6 @@ extern "C" { #define HAS_SCREEN 0 -// Enable Traffic Management Module for testing on T1000-E -// NRF52840 has 256KB RAM - 1024 entries uses ~10KB -#ifndef HAS_TRAFFIC_MANAGEMENT -#define HAS_TRAFFIC_MANAGEMENT 1 -#endif -#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1024 -#endif - #ifdef __cplusplus } #endif From 5338923ba395504f2c188a1828bdcafa0af1b67e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 6 Jul 2026 08:30:13 -0500 Subject: [PATCH 2/4] Add central memory-class ladder (MemClass.h) with fail-safe-small defaults The 2.8.0 nRF52840 heap exhaustion happened because each RAM-sized cache picked its per-platform tier from its own chip #ifdef ladder, and every ladder's fallthrough default was its largest non-PSRAM tier - nRF52 was never named, so it silently got 1000-entry caches on a ~115 KB arena. This introduces src/memory/MemClass.h: a single MESHTASTIC_MEM_CLASS (TINY / SMALL / MEDIUM / LARGE) ranked by usable app heap after platform overheads, with the deliberate property that an unclassified chip lands in SMALL - a new target boots with small caches until someone opts it up in one visible place. The TMM cache, warm store, MAX_RX_TOPHONE and MAX_SATELLITE_NODES ladders in mesh-pb-constants.h now key off the class; branches pinned by something other than RAM stay explicit and say why (nRF52840's SoftDevice arena, RP2040's warm.dat watchdog bound). MAX_NUM_NODES intentionally stays separate - it is flash-shaped (nodes.proto vs LittleFS), not heap-shaped. A per-class MESHTASTIC_BOOT_CACHE_BUDGET static_assert now covers the three big boot-allocated caches, so the next cache-adding PR that would blow a small platform's budget fails to compile instead of exhausting heap in the field. No values change for any existing target: rak4631, tbeam, rak11310 and wio-e5 build byte-identical before/after; all ladders remain #ifndef-guarded so variant overrides keep working. --- src/memory/MemClass.h | 72 +++++++++++++++++++++++++++++++ src/mesh/mesh-pb-constants.h | 83 +++++++++++++++++++++++------------- 2 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 src/memory/MemClass.h diff --git a/src/memory/MemClass.h b/src/memory/MemClass.h new file mode 100644 index 00000000000..9d9f6cedeef --- /dev/null +++ b/src/memory/MemClass.h @@ -0,0 +1,72 @@ +#pragma once + +// Central memory-class ladder: every RAM-sized cache in the tree keys its +// per-platform tier off MESHTASTIC_MEM_CLASS instead of growing its own chip +// #ifdef ladder. Born out of the 2.8.0 nRF52840 heap exhaustion: each cache's +// private ladder fell through to its *largest* non-PSRAM tier for any chip it +// didn't name, and nRF52 was never named. Here the unknown-chip default is the +// SMALL class - a new target boots with small caches until someone classifies +// it below, deliberately. +// +// Classes rank *usable app heap after platform overheads* (SoftDevice, WiFi+BLE +// stacks), not raw RAM. That is why classic ESP32 (520 KB raw, ~200-250 KB free +// with radios up) shares a class with nRF52840 (256 KB raw, ~115 KB arena after +// SoftDevice + FreeRTOS stacks). +// +// MEM_CLASS_TINY <32 KB free heap STM32WL +// MEM_CLASS_SMALL ~100-250 KB nRF52840, classic ESP32/S2/C3, RP2040 +// MEM_CLASS_MEDIUM ~250-500 KB, no PSRAM ESP32-S3/C6/P4 without PSRAM, RP2350 +// MEM_CLASS_LARGE PSRAM or host ESP32-S3+PSRAM, portduino +// +// Compare ordinally: #if MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM ... +// +// Overrides: a variant may predefine MESHTASTIC_MEM_CLASS (variant.h or +// -D build flag) or any individual cache constant - every consumer ladder +// stays #ifndef-guarded, so the most specific definition wins. +// +// Deliberately NOT classed here: MAX_NUM_NODES (bounded by nodes.proto fitting +// the platform's filesystem, i.e. flash-shaped, not heap-shaped) and any cache +// whose size is pinned by a hardware quirk - those branches say why inline +// (e.g. WARM_NODE_COUNT on RP2040 is watchdog-bound). +// +// This header is included very early (mesh-pb-constants.h), so it may only use +// macros that exist without configuration.h: toolchain/board -D flags +// (NRF52840_XXAA, CONFIG_IDF_TARGET_*, BOARD_HAS_PSRAM) and the ARCH_* macros +// the surrounding ladders already depend on. + +#define MEM_CLASS_TINY 1 +#define MEM_CLASS_SMALL 2 +#define MEM_CLASS_MEDIUM 3 +#define MEM_CLASS_LARGE 4 + +#ifndef MESHTASTIC_MEM_CLASS +#if defined(ARCH_STM32WL) +#define MESHTASTIC_MEM_CLASS MEM_CLASS_TINY +#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) +#define MESHTASTIC_MEM_CLASS MEM_CLASS_LARGE +#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4) +#define MESHTASTIC_MEM_CLASS MEM_CLASS_MEDIUM +#else +// Classic ESP32 / S2 / C3, all nRF52, RP2040/RP2350, and - by design - any chip +// nobody has classified yet: small caches are a recoverable default, an +// exhausted heap is not. New RAM-rich targets opt up by adding a branch above. +#define MESHTASTIC_MEM_CLASS MEM_CLASS_SMALL +#endif +#endif // MESHTASTIC_MEM_CLASS + +// Compile-time ceiling for the boot-allocated mesh caches sized off this class +// (TMM cache + warm store + packet history). mesh-pb-constants.h static_asserts +// their sum against it, so the next cache-adding PR fails to build instead of +// exhausting a 115 KB arena in the field. Raising a budget is allowed - it is a +// one-line diff a reviewer can see and question. +#ifndef MESHTASTIC_BOOT_CACHE_BUDGET +#if MESHTASTIC_MEM_CLASS <= MEM_CLASS_TINY +#define MESHTASTIC_BOOT_CACHE_BUDGET (8 * 1024) +#elif MESHTASTIC_MEM_CLASS == MEM_CLASS_SMALL +#define MESHTASTIC_BOOT_CACHE_BUDGET (16 * 1024) +#elif MESHTASTIC_MEM_CLASS == MEM_CLASS_MEDIUM +#define MESHTASTIC_BOOT_CACHE_BUDGET (32 * 1024) +#else +#define MESHTASTIC_BOOT_CACHE_BUDGET (256 * 1024) +#endif +#endif // MESHTASTIC_BOOT_CACHE_BUDGET diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index 040bdf732c0..b62d4f10aec 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -1,12 +1,17 @@ #pragma once #include +#include "memory/MemClass.h" #include "mesh/generated/meshtastic/admin.pb.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "mesh/generated/meshtastic/localonly.pb.h" #include "mesh/generated/meshtastic/mesh.pb.h" // this file defines constants which come from mesh.options +// +// Sizing policy: RAM-shaped cache tiers below key off MESHTASTIC_MEM_CLASS +// (see memory/MemClass.h) so unclassified chips fail safe-small. Branches that +// deviate from their class are pinned by something other than RAM and say why. // Tricky macro to let you find the sizeof a type member #define member_size(type, member) sizeof(((type *)0)->member) @@ -23,8 +28,13 @@ // the 8 classic ESP32 has shipped with for years; drops start when a stalled phone/serial client has // 16 packets queued. #define MAX_RX_TOPHONE 16 -#else +#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM || defined(ARCH_RP2040) || defined(CONFIG_IDF_TARGET_ESP32C3) || \ + defined(ARCH_STM32WL) +// RP2040/RP2350, ESP32-C3 and STM32WL keep their historical 32 (no field pressure to cut them; +// STM32WL's pool is dynamic, so the constant only bounds in-flight packets there). #define MAX_RX_TOPHONE 32 +#else +#define MAX_RX_TOPHONE 16 // unclassified small parts: fail safe-small #endif #endif @@ -115,7 +125,7 @@ static inline int get_max_num_nodes() /// flash-rich hosts get a cap >= their hot store (satellites for every node, as /// before the cap existed) while constrained parts stay at 40. #ifndef MAX_SATELLITE_NODES -#if (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) +#if MESHTASTIC_MEM_CLASS >= MEM_CLASS_LARGE #define MAX_SATELLITE_NODES 250 #else #define MAX_SATELLITE_NODES 40 // nRF52840, generic ESP32, and ESP32-S3 without PSRAM @@ -126,7 +136,7 @@ static inline int get_max_num_nodes() /// so DMs to/from them keep decrypting. 0 disables it; size is per-platform /// below, persisted to /prefs/warm.dat (or the nRF52840 raw-flash ring). #ifndef WARM_NODE_COUNT -#if defined(ARCH_STM32WL) +#if MESHTASTIC_MEM_CLASS <= MEM_CLASS_TINY #define WARM_NODE_COUNT 0 #elif defined(NRF52840_XXAA) // Keyed on the NRF52840_XXAA build flag, not ARCH_NRF52: the latter (from @@ -136,30 +146,28 @@ static inline int get_max_num_nodes() // arena, which 2.8.0 field reports showed at 99% use; 120 hot + 100 warm // identities still covers meshes well past the hot cap. #define WARM_NODE_COUNT 100 -#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) +#elif defined(ARCH_RP2040) +// Class-deviant on purpose: bounded so the warm.dat write fits the 8s watchdog (#10746), +// not by RAM (RP2040 264 KB / RP2350 520 KB could hold more). +#define WARM_NODE_COUNT 150 +#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_LARGE #define WARM_NODE_COUNT 2000 // PSRAM-equipped ESP32-S3 / native host; warm cache in PSRAM (~80 KB) -#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4) +#elif MESHTASTIC_MEM_CLASS == MEM_CLASS_MEDIUM #define WARM_NODE_COUNT 150 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~6 KB heap (#10705) -#elif defined(ARCH_ESP32) -#define WARM_NODE_COUNT 100 // classic ESP32 (520 KB) / S2 (320 KB) / C3 (400 KB): tightest free heap w/ BLE+WiFi, ~4 KB (#10705) -#elif defined(ARCH_RP2040) -#define WARM_NODE_COUNT 150 // RP2040 (264 KB) / RP2350 (520 KB): bounded so warm.dat write fits the 8s watchdog (#10746) #else -// nRF52840 is handled explicitly above (raw-flash ring). Any other nRF52 (non-XXAA) and any future -// non-ESP32/non-RP LittleFS part lands here. Reviewed after the 2.8.0 nRF52840 heap-exhaustion -// reports: fail small (match nRF52840) so an unclassified RAM-constrained part can't boot-allocate -// 12.8 KB; RAM-rich targets should opt up explicitly in their variant. -#define WARM_NODE_COUNT 100 // other LittleFS-backed parts (e.g. non-nRF52840 nRF52) -#endif // platform -#endif // WARM_NODE_COUNT +// MEM_CLASS_SMALL: classic ESP32/S2/C3 (~4 KB heap, #10705) and anything unclassified - +// fail small so a new RAM-constrained part can't boot-allocate 12.8 KB (2.8.0 lesson). +#define WARM_NODE_COUNT 100 +#endif // platform +#endif // WARM_NODE_COUNT /// Max number of channels allowed #define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0])) // Traffic Management module configuration -// Enabled by default; STM32WL is excluded due to RAM constraints (MAX_NUM_NODES=10). +// Enabled by default; TINY parts (STM32WL) are excluded due to RAM constraints (MAX_NUM_NODES=10). // Disable per-variant by defining HAS_TRAFFIC_MANAGEMENT=0 in variant.h -#ifdef ARCH_STM32WL +#if MESHTASTIC_MEM_CLASS <= MEM_CLASS_TINY #define HAS_TRAFFIC_MANAGEMENT 0 #endif #ifndef HAS_TRAFFIC_MANAGEMENT @@ -181,25 +189,40 @@ static inline int get_max_num_nodes() #ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE #if !HAS_TRAFFIC_MANAGEMENT #define TRAFFIC_MANAGEMENT_CACHE_SIZE 0 -#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 // PSRAM-equipped ESP32-S3 / native host -#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4) -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 500 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~5 KB heap (#10705) -#elif defined(ARCH_ESP32) -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 400 // classic ESP32 / S2 / C3: tightest free heap, ~4 KB (#10705) #elif defined(NRF52840_XXAA) -// Keyed on NRF52840_XXAA (not ARCH_NRF52) for the same include-order reason as WARM_NODE_COUNT above. -// The 256 KB part shares its ~115 KB heap arena with SoftDevice and the FreeRTOS task stacks; 2.8.0 -// field reports hit 99% heap use with the old 1000-entry (~10 KB) cache. 250 entries (2.5 KB) still -// tracks >2x the 120-node hot store; LRU victim recycling absorbs busier meshes. +// Class-deviant on purpose (SMALL would be 400): the 256 KB part shares its ~115 KB heap arena with +// SoftDevice and the FreeRTOS task stacks; 2.8.0 field reports hit 99% heap use with the old +// 1000-entry (~10 KB) cache. 250 entries (2.5 KB) still tracks >2x the 120-node hot store; LRU +// victim recycling absorbs busier meshes. #define TRAFFIC_MANAGEMENT_CACHE_SIZE 250 +#elif MESHTASTIC_MEM_CLASS >= MEM_CLASS_LARGE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 // PSRAM-equipped ESP32-S3 / native host +#elif MESHTASTIC_MEM_CLASS == MEM_CLASS_MEDIUM +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 500 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~5 KB heap (#10705) #else -// RP2040/RP2350 and anything unclassified land here: match the classic-ESP32 tier rather than -// defaulting large. RAM-rich targets should opt up explicitly in their variant. +// MEM_CLASS_SMALL: classic ESP32/S2/C3 (~4 KB heap, #10705), RP2040/RP2350, and anything +// unclassified - fail small rather than defaulting large (2.8.0 lesson). #define TRAFFIC_MANAGEMENT_CACHE_SIZE 400 #endif #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE +// Enforce the per-class boot-cache budget (memory/MemClass.h) over the three big +// boot-allocated mesh caches. Entry sizes are pinned by static_asserts at their +// definitions: TMM UnifiedCacheEntry = 10 B (TrafficManagementModule.h), +// WarmNodeEntry = 40 B (WarmNodeStore.h), PacketHistory::PacketRecord = 20 B +// (PacketHistory.h; its optional hash index is excluded here). Skipped where +// MAX_NUM_NODES is not a compile-time constant: ESP32-S3 (runtime, from flash +// size) and portduino (runtime, from portduino_config.MaxNodes via variant.h). +// If this fires on a new feature: shrink the cache for this class, or raise the +// class budget in MemClass.h as a visible, reviewable decision. +#if !defined(CONFIG_IDF_TARGET_ESP32S3) && !defined(ARCH_PORTDUINO) +static_assert((uint32_t)TRAFFIC_MANAGEMENT_CACHE_SIZE * 10u + (uint32_t)WARM_NODE_COUNT * 40u + + 20u * (uint32_t)(MAX_NUM_NODES * 2 > 100 ? MAX_NUM_NODES * 2 : 100) /* PACKETHISTORY_MAX mirror */ + <= MESHTASTIC_BOOT_CACHE_BUDGET, + "Boot-allocated mesh caches exceed this memory class's budget - shrink a tier or consciously raise " + "MESHTASTIC_BOOT_CACHE_BUDGET in memory/MemClass.h"); +#endif + /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic /// returns the encoded packet size size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct); From 85a3c2f948d09bb063c31cfd2ee321ed17748719 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 6 Jul 2026 14:08:53 -0500 Subject: [PATCH 3/4] Address review: fix RP2350 class-table doc, add PacketRecord static_assert - MemClass.h's class table claimed RP2350 was MEDIUM while the mapping ladder classifies it SMALL (with RP2040) - the table now matches the ladder, with a note that RP2350 is a MEDIUM candidate whenever someone wants to tune it up (kept SMALL here so this header stays a behavioral no-op). - The boot-cache budget comment referenced a static_assert pinning PacketHistory::PacketRecord at 20 B that did not exist (only a layout comment). Add the real static_assert so the budget math in mesh-pb-constants.h fails to compile if the record layout changes. Also merges develop (the base #10898 landed there as a squash, which is what made this stacked branch conflict); develop's mesh-pb-constants.h is byte-identical to this branch's base, so the resolution keeps the MemClass ladder unchanged. rak4631 and wio-e5 build green; test_packet_history 47/47. --- src/memory/MemClass.h | 7 +++++-- src/mesh/PacketHistory.h | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/memory/MemClass.h b/src/memory/MemClass.h index 9d9f6cedeef..d2afde7a1c8 100644 --- a/src/memory/MemClass.h +++ b/src/memory/MemClass.h @@ -14,10 +14,13 @@ // SoftDevice + FreeRTOS stacks). // // MEM_CLASS_TINY <32 KB free heap STM32WL -// MEM_CLASS_SMALL ~100-250 KB nRF52840, classic ESP32/S2/C3, RP2040 -// MEM_CLASS_MEDIUM ~250-500 KB, no PSRAM ESP32-S3/C6/P4 without PSRAM, RP2350 +// MEM_CLASS_SMALL ~100-250 KB nRF52840, classic ESP32/S2/C3, RP2040/RP2350 +// MEM_CLASS_MEDIUM ~250-500 KB, no PSRAM ESP32-S3/C6/P4 without PSRAM // MEM_CLASS_LARGE PSRAM or host ESP32-S3+PSRAM, portduino // +// RP2350 (520 KB) rides with RP2040 in SMALL to keep this header a behavioral +// no-op; it is a MEDIUM candidate whenever someone wants to tune it up. +// // Compare ordinally: #if MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM ... // // Overrides: a variant may predefine MESHTASTIC_MEM_CLASS (variant.h or diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h index e45df847404..84af59874e6 100644 --- a/src/mesh/PacketHistory.h +++ b/src/mesh/PacketHistory.h @@ -24,6 +24,8 @@ class PacketHistory // bit 3-5: our hop limit when we first transmitted it uint8_t relayed_by[NUM_RELAYERS]; // Array of nodes that relayed this packet }; // 4B + 4B + 4B + 1B + 1B + 6B = 20B + static_assert(sizeof(PacketRecord) == 20, + "PacketRecord size feeds the boot-cache budget math in mesh-pb-constants.h - update both together"); uint32_t recentPacketsCapacity = 0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets. From 08476c2791d51aa0dd9d1d1e124aa02591749ae5 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 6 Jul 2026 14:37:17 -0500 Subject: [PATCH 4/4] Address review: share PACKETHISTORY_MAX, trim policy comments - Hoist PACKETHISTORY_MAX from PacketHistory.cpp into mesh-pb-constants.h (next to the MAX_NUM_NODES it derives from) so the constructor clamp and the boot-cache budget static_assert use one definition instead of hand-mirrored arithmetic that could drift. The expression stays valid where MAX_NUM_NODES resolves at runtime (ESP32-S3, portduino); the pointless 2.0 double math becomes integer. - Trim the MemClass.h header (36 -> 16 comment lines) and the budget / sizing-policy comments per the repo comment-length guideline, keeping the class table, the fail-safe-small rule, the override mechanism, and the include-order constraint. rak4631 (compile-time MAX_NUM_NODES) and heltec-v3 (runtime) build green; test_packet_history 47/47. --- src/memory/MemClass.h | 48 ++++++++++-------------------------- src/mesh/PacketHistory.cpp | 4 --- src/mesh/mesh-pb-constants.h | 17 ++++++++----- 3 files changed, 24 insertions(+), 45 deletions(-) diff --git a/src/memory/MemClass.h b/src/memory/MemClass.h index d2afde7a1c8..83e08757f3c 100644 --- a/src/memory/MemClass.h +++ b/src/memory/MemClass.h @@ -1,41 +1,21 @@ #pragma once -// Central memory-class ladder: every RAM-sized cache in the tree keys its -// per-platform tier off MESHTASTIC_MEM_CLASS instead of growing its own chip -// #ifdef ladder. Born out of the 2.8.0 nRF52840 heap exhaustion: each cache's -// private ladder fell through to its *largest* non-PSRAM tier for any chip it -// didn't name, and nRF52 was never named. Here the unknown-chip default is the -// SMALL class - a new target boots with small caches until someone classifies -// it below, deliberately. -// -// Classes rank *usable app heap after platform overheads* (SoftDevice, WiFi+BLE -// stacks), not raw RAM. That is why classic ESP32 (520 KB raw, ~200-250 KB free -// with radios up) shares a class with nRF52840 (256 KB raw, ~115 KB arena after -// SoftDevice + FreeRTOS stacks). +// Central memory-class ladder: RAM-sized caches key their per-platform tier off +// MESHTASTIC_MEM_CLASS. Classes rank *usable app heap after platform overheads* +// (SoftDevice, WiFi+BLE stacks) - not raw RAM - and an unclassified chip lands in +// SMALL on purpose: small caches are a recoverable default, an exhausted heap is not. // // MEM_CLASS_TINY <32 KB free heap STM32WL // MEM_CLASS_SMALL ~100-250 KB nRF52840, classic ESP32/S2/C3, RP2040/RP2350 // MEM_CLASS_MEDIUM ~250-500 KB, no PSRAM ESP32-S3/C6/P4 without PSRAM // MEM_CLASS_LARGE PSRAM or host ESP32-S3+PSRAM, portduino // -// RP2350 (520 KB) rides with RP2040 in SMALL to keep this header a behavioral -// no-op; it is a MEDIUM candidate whenever someone wants to tune it up. -// -// Compare ordinally: #if MESHTASTIC_MEM_CLASS >= MEM_CLASS_MEDIUM ... -// -// Overrides: a variant may predefine MESHTASTIC_MEM_CLASS (variant.h or -// -D build flag) or any individual cache constant - every consumer ladder -// stays #ifndef-guarded, so the most specific definition wins. -// -// Deliberately NOT classed here: MAX_NUM_NODES (bounded by nodes.proto fitting -// the platform's filesystem, i.e. flash-shaped, not heap-shaped) and any cache -// whose size is pinned by a hardware quirk - those branches say why inline -// (e.g. WARM_NODE_COUNT on RP2040 is watchdog-bound). -// -// This header is included very early (mesh-pb-constants.h), so it may only use -// macros that exist without configuration.h: toolchain/board -D flags -// (NRF52840_XXAA, CONFIG_IDF_TARGET_*, BOARD_HAS_PSRAM) and the ARCH_* macros -// the surrounding ladders already depend on. +// Compare ordinally (>=). RP2350 rides with RP2040 so this header stays a behavioral +// no-op (MEDIUM candidate later). Variants may predefine MESHTASTIC_MEM_CLASS or any +// cache constant - consumer ladders stay #ifndef-guarded. MAX_NUM_NODES is deliberately +// unclassed (flash-shaped: nodes.proto vs filesystem). Included before configuration.h, +// so only toolchain/board -D macros and the ARCH_* macros the ladders already use are +// safe here. #define MEM_CLASS_TINY 1 #define MEM_CLASS_SMALL 2 @@ -57,11 +37,9 @@ #endif #endif // MESHTASTIC_MEM_CLASS -// Compile-time ceiling for the boot-allocated mesh caches sized off this class -// (TMM cache + warm store + packet history). mesh-pb-constants.h static_asserts -// their sum against it, so the next cache-adding PR fails to build instead of -// exhausting a 115 KB arena in the field. Raising a budget is allowed - it is a -// one-line diff a reviewer can see and question. +// Ceiling for the boot-allocated mesh caches (TMM + warm store + packet history); +// mesh-pb-constants.h static_asserts their sum, so an oversized cache fails the build. +// Raising a budget is allowed - as a visible, reviewable one-line diff. #ifndef MESHTASTIC_BOOT_CACHE_BUDGET #if MESHTASTIC_MEM_CLASS <= MEM_CLASS_TINY #define MESHTASTIC_BOOT_CACHE_BUDGET (8 * 1024) diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index 9627d24252f..2522c30ca56 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -9,10 +9,6 @@ #endif #include "Throttle.h" -#define PACKETHISTORY_MAX \ - max((uint32_t)(MAX_NUM_NODES * 2.0), \ - (uint32_t)100) // x2..3 Should suffice. Empirical setup. 16B per record malloc'ed, but no less than 100 - #define RECENT_WARN_AGE (10 * 60 * 1000L) // Warn if the packet that gets removed was more recent than 10 min #define VERBOSE_PACKET_HISTORY 0 // Set to 1 for verbose logging, 2 for heavy debugging diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index b62d4f10aec..9a84e83a91e 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -9,9 +9,8 @@ // this file defines constants which come from mesh.options // -// Sizing policy: RAM-shaped cache tiers below key off MESHTASTIC_MEM_CLASS -// (see memory/MemClass.h) so unclassified chips fail safe-small. Branches that -// deviate from their class are pinned by something other than RAM and say why. +// RAM-shaped cache tiers key off MESHTASTIC_MEM_CLASS (memory/MemClass.h) so +// unclassified chips fail safe-small; class-deviant branches say why inline. // Tricky macro to let you find the sizeof a type member #define member_size(type, member) sizeof(((type *)0)->member) @@ -119,6 +118,13 @@ static inline int get_max_num_nodes() #endif // platform #endif // MAX_NUM_NODES +/// Packet-history capacity: 2x the hot store so dedup/relayer state survives a +/// full mesh, floored at 100. Shared by PacketHistory's constructor clamp and +/// the boot-cache budget assert below so the two cannot drift. +#ifndef PACKETHISTORY_MAX +#define PACKETHISTORY_MAX (MAX_NUM_NODES * 2 > 100 ? (uint32_t)(MAX_NUM_NODES * 2) : (uint32_t)100) +#endif + /// Per-map cap (position/telemetry/environment/status): only the freshest /// MAX_SATELLITE_NODES nodes keep satellite payloads, the rest just the /// NodeInfoLite header. RAM-bound (the maps are internal-SRAM, not PSRAM), so @@ -216,9 +222,8 @@ static inline int get_max_num_nodes() // If this fires on a new feature: shrink the cache for this class, or raise the // class budget in MemClass.h as a visible, reviewable decision. #if !defined(CONFIG_IDF_TARGET_ESP32S3) && !defined(ARCH_PORTDUINO) -static_assert((uint32_t)TRAFFIC_MANAGEMENT_CACHE_SIZE * 10u + (uint32_t)WARM_NODE_COUNT * 40u + - 20u * (uint32_t)(MAX_NUM_NODES * 2 > 100 ? MAX_NUM_NODES * 2 : 100) /* PACKETHISTORY_MAX mirror */ - <= MESHTASTIC_BOOT_CACHE_BUDGET, +static_assert((uint32_t)TRAFFIC_MANAGEMENT_CACHE_SIZE * 10u + (uint32_t)WARM_NODE_COUNT * 40u + 20u * PACKETHISTORY_MAX <= + MESHTASTIC_BOOT_CACHE_BUDGET, "Boot-allocated mesh caches exceed this memory class's budget - shrink a tier or consciously raise " "MESHTASTIC_BOOT_CACHE_BUDGET in memory/MemClass.h"); #endif