v1.0.8: Fix heap exhaustion from unbounded random_blobs

Root cause: Python Reticulum trims random_blobs per destination entry
(MAX_RANDOM_BLOBS=64 in-memory, PERSIST_RANDOM_BLOBS=32 on disk).
The C++ firmware had these constants defined but NEVER enforced them,
causing unbounded growth. With 21 paths x 60+ blobs x ~90 bytes each,
the destination table alone consumed ~57KB of the ESP32 324KB heap.

Fixes:
- Trim random_blobs after insert (matching Python behavior)
- Trim random_blobs on deserialization from flash
- Trim random_blobs to PERSIST_RANDOM_BLOBS on serialization
- Enforce _path_table_maxpersist when writing path table (was declared
  but never used - write_path_table saved everything)
- Reduce MCU constants: MAX_RANDOM_BLOBS 64->16, PERSIST_RANDOM_BLOBS 32->8
- Reduce path_table_maxsize 128->24, maxpersist 32->12
- Add memory diagnostic after path table load
- Trim loaded paths to maxsize on startup via cull_path_table()

Results: destination_table 21KB->5.8KB, free heap 63K(22%)->156K(49%)
This commit is contained in:
James L
2026-02-27 17:51:20 -05:00
parent 59784a34fd
commit 4e5d4ee8ad
4 changed files with 95 additions and 13 deletions

View File

@@ -648,12 +648,11 @@ void setup() {
// ── Boundary Mode: Load config and optionally set up WiFi + TCP ──
HEAD("Boundary Mode: Initializing...", RNS::LOG_TRACE);
// With TLSF pool moved to PSRAM we have plenty of room.
// 128 path entries supports ~15-20 devices comfortably.
// cull_path_table() is patched to evict backbone paths first, preserving
// local (LoRa / local-TCP) paths needed for inbound message delivery.
RNS::Transport::path_table_maxsize(128);
RNS::Transport::path_table_maxpersist(32);
// ESP32 has only ~324KB heap. Each path entry with random_blobs costs
// ~200-500 bytes. Keep tables small to avoid heap exhaustion.
// cull_path_table() evicts backbone paths first, preserving local ones.
RNS::Transport::path_table_maxsize(24);
RNS::Transport::path_table_maxpersist(12);
boundary_load_config();
// Start WiFi if enabled

View File

@@ -2256,6 +2256,26 @@ static bool is_backbone_interface(const Interface& iface) {
random_blobs.insert(random_blob);
// Trim random_blobs to prevent unbounded memory growth
// (matching Python: random_blobs = random_blobs[-MAX_RANDOM_BLOBS:])
if (random_blobs.size() > MAX_RANDOM_BLOBS) {
// Keep only the MAX_RANDOM_BLOBS blobs with the highest
// timestamps (bytes 5-9 big-endian). Extract, sort by
// timestamp desc, keep top N, rebuild set.
std::vector<Bytes> blob_vec(random_blobs.begin(), random_blobs.end());
std::sort(blob_vec.begin(), blob_vec.end(), [](const Bytes& a, const Bytes& b) {
// Sort descending by timestamp (bytes 5-9)
uint64_t ts_a = OS::from_bytes_big_endian(a.data() + 5, 5);
uint64_t ts_b = OS::from_bytes_big_endian(b.data() + 5, 5);
return ts_a > ts_b;
});
random_blobs.clear();
for (size_t i = 0; i < MAX_RANDOM_BLOBS && i < blob_vec.size(); i++) {
random_blobs.insert(blob_vec[i]);
}
TRACEF("Trimmed random_blobs to %d entries for %s", random_blobs.size(), packet.destination_hash().toHex().c_str());
}
if ((Reticulum::transport_enabled() || Transport::from_local_client(packet)) && packet.context() != Type::Packet::PATH_RESPONSE) {
// Insert announce into announce table for retransmission
@@ -3993,6 +4013,21 @@ TRACEF("Transport::start: buffer size %d bytes", Persistence::_buffer.size());
for (const auto& destination_hash : invalid_paths) {
_destination_table.erase(destination_hash);
}
// Enforce maxsize on loaded paths (trim oldest if over limit)
if (_destination_table.size() > _path_table_maxsize) {
DEBUGF("Transport::start: trimming loaded path table from %d to %d entries", _destination_table.size(), _path_table_maxsize);
cull_path_table();
}
// Memory diagnostic after path table load
size_t total_blobs = 0;
for (const auto& [hash, entry] : _destination_table) {
total_blobs += entry._random_blobs.size();
}
DEBUGF("Transport::start: path table: %d entries, %d total random_blobs (est. %d bytes)",
_destination_table.size(), total_blobs, total_blobs * 90);
return true;
}
else {
@@ -4044,6 +4079,24 @@ TRACEF("Transport::start: buffer size %d bytes", Persistence::_buffer.size());
double save_start = OS::time();
DEBUGF("Saving %d path table entries to storage...", _destination_table.size());
// Enforce maxpersist: create a trimmed copy for serialization
// keeping only the most recently used entries (by timestamp)
std::map<Bytes, DestinationEntry> persist_table;
if (_destination_table.size() <= _path_table_maxpersist) {
persist_table = _destination_table;
}
else {
// Sort by timestamp descending, keep only maxpersist entries
std::vector<std::pair<Bytes, DestinationEntry>> sorted_entries(_destination_table.begin(), _destination_table.end());
std::sort(sorted_entries.begin(), sorted_entries.end(), [](const std::pair<Bytes, DestinationEntry>& a, const std::pair<Bytes, DestinationEntry>& b) {
return a.second._timestamp > b.second._timestamp;
});
for (size_t i = 0; i < _path_table_maxpersist && i < sorted_entries.size(); i++) {
persist_table.insert(sorted_entries[i]);
}
DEBUGF("Trimmed path table from %d to %d entries for persistence", _destination_table.size(), persist_table.size());
}
/*p
serialised_destinations = []
for destination_hash in Transport.destination_table:
@@ -4087,7 +4140,7 @@ TRACEF("Transport::start: buffer size %d bytes", Persistence::_buffer.size());
#if CUSTOM
{
Persistence::_document.set(_destination_table);
Persistence::_document.set(persist_table);
TRACEF("Transport::write_path_table: doc size %d bytes", Persistence::_document.memoryUsage());
//size_t size = 8192;
@@ -4141,7 +4194,7 @@ TRACE("Transport::write_path_table: buffer size " + std::to_string(Persistence::
TRACE("Transport::write_path_table: failed to serialize");
}
#else // CUSTOM
uint32_t crc = Persistence::crc(_destination_table);
uint32_t crc = Persistence::crc(persist_table);
if (_destination_table_crc > 0 && crc == _destination_table_crc) {
TRACE("Transport::write_path_table: no change detected, skipping write");
}
@@ -4149,8 +4202,8 @@ TRACE("Transport::write_path_table: buffer size " + std::to_string(Persistence::
TRACE("Transport::write_path_table: change detected, writing...");
char destination_table_path[Type::Reticulum::FILEPATH_MAXSIZE];
snprintf(destination_table_path, Type::Reticulum::FILEPATH_MAXSIZE, "%s/destination_table", Reticulum::_storagepath);
if (Persistence::serialize(_destination_table, destination_table_path, _destination_table_crc) > 0) {
TRACEF("Transport::write_path_table: wrote %d entries, %d bytes", _destination_table.size(), Persistence::_buffer.size());
if (Persistence::serialize(persist_table, destination_table_path, _destination_table_crc) > 0) {
TRACEF("Transport::write_path_table: wrote %d entries, %d bytes", persist_table.size(), Persistence::_buffer.size());
success = true;
}
}

View File

@@ -430,8 +430,8 @@ namespace RNS { namespace Type {
//static const uint16_t MAX_RECEIPTS = 1024; // Maximum number of receipts to keep track of
static const uint16_t MAX_RECEIPTS = 20; // Maximum number of receipts to keep track of
static const uint8_t MAX_RATE_TIMESTAMPS = 16; // Maximum number of announce timestamps to keep per destination
static const uint8_t PERSIST_RANDOM_BLOBS = 32; // Maximum number of random blobs per destination to persist to disk
static const uint8_t MAX_RANDOM_BLOBS = 64; // Maximum number of random blobs per destination to keep in memory
static const uint8_t PERSIST_RANDOM_BLOBS = 8; // Maximum number of random blobs per destination to persist to disk (reduced for MCU memory)
static const uint8_t MAX_RANDOM_BLOBS = 16; // Maximum number of random blobs per destination to keep in memory (reduced for MCU memory)
// CBA MCU
//static const uint32_t DESTINATION_TIMEOUT = 60*60*24*7; // Destination table entries are removed if unused for one week

View File

@@ -4,6 +4,7 @@
// then they MUST be included BEFORE this header is included.
#include "Transport.h"
#include "Type.h"
#include "Utilities/OS.h"
#include <ArduinoJson.h>
@@ -278,7 +279,23 @@ namespace ArduinoJson {
dst["received_from"] = src._received_from;
dst["announce_hops"] = src._hops;
dst["expires"] = src._expires;
dst["random_blobs"] = src._random_blobs;
// Trim random_blobs to PERSIST_RANDOM_BLOBS before writing to disk
if (src._random_blobs.size() > RNS::Type::Transport::PERSIST_RANDOM_BLOBS) {
std::vector<RNS::Bytes> blob_vec(src._random_blobs.begin(), src._random_blobs.end());
std::sort(blob_vec.begin(), blob_vec.end(), [](const RNS::Bytes& a, const RNS::Bytes& b) {
uint64_t ts_a = RNS::Utilities::OS::from_bytes_big_endian(a.data() + 5, 5);
uint64_t ts_b = RNS::Utilities::OS::from_bytes_big_endian(b.data() + 5, 5);
return ts_a > ts_b;
});
std::set<RNS::Bytes> trimmed;
for (size_t i = 0; i < RNS::Type::Transport::PERSIST_RANDOM_BLOBS && i < blob_vec.size(); i++) {
trimmed.insert(blob_vec[i]);
}
dst["random_blobs"] = trimmed;
}
else {
dst["random_blobs"] = src._random_blobs;
}
/*
//dst["interface_hash"] = src._receiving_interface;
if (src._receiving_interface) {
@@ -320,6 +337,19 @@ namespace ArduinoJson {
dst._hops = src["announce_hops"];
dst._expires = src["expires"];
dst._random_blobs = src["random_blobs"].as<std::set<RNS::Bytes>>();
// Trim random_blobs loaded from flash to MAX_RANDOM_BLOBS
if (dst._random_blobs.size() > RNS::Type::Transport::MAX_RANDOM_BLOBS) {
std::vector<RNS::Bytes> blob_vec(dst._random_blobs.begin(), dst._random_blobs.end());
std::sort(blob_vec.begin(), blob_vec.end(), [](const RNS::Bytes& a, const RNS::Bytes& b) {
uint64_t ts_a = RNS::Utilities::OS::from_bytes_big_endian(a.data() + 5, 5);
uint64_t ts_b = RNS::Utilities::OS::from_bytes_big_endian(b.data() + 5, 5);
return ts_a > ts_b;
});
dst._random_blobs.clear();
for (size_t i = 0; i < RNS::Type::Transport::MAX_RANDOM_BLOBS && i < blob_vec.size(); i++) {
dst._random_blobs.insert(blob_vec[i]);
}
}
/*
//dst._receiving_interface = src["interface_hash"];
RNS::Bytes interface_hash = src["interface_hash"];