diff --git a/examples/link/main.cpp b/examples/link/main.cpp index 186b0e8b..60e9d424 100644 --- a/examples/link/main.cpp +++ b/examples/link/main.cpp @@ -1337,7 +1337,7 @@ void cleanup_handler(int signum) { // starts up the desired program mode. int main(int argc, char *argv[]) { -#if defined(MEM_LOG) +#if defined(RNS_MEM_LOG) RNS::loglevel(RNS::LOG_MEM); #else // Use DEBUG to see resource handling diff --git a/examples/link/platformio.ini b/examples/link/platformio.ini index a29a4b46..f47c04f8 100644 --- a/examples/link/platformio.ini +++ b/examples/link/platformio.ini @@ -21,7 +21,7 @@ build_type = debug build_flags = -DRNS_USE_ALLOCATOR=1 -DRNS_USE_TLSF=1 - ;-DMEM_LOG + ;-DRNS_MEM_LOG lib_deps = ArduinoJson@^7.4.2 MsgPack@^0.4.2 diff --git a/examples/lora_announce/platformio.ini b/examples/lora_announce/platformio.ini index 11f1f7e3..ba6e62b8 100644 --- a/examples/lora_announce/platformio.ini +++ b/examples/lora_announce/platformio.ini @@ -18,7 +18,7 @@ monitor_speed = 115200 upload_speed = 921600 build_type = debug build_flags = - ;-DMEM_LOG=1 + ;-DRNS_MEM_LOG=1 -DRNS_USE_ALLOCATOR=1 -DRNS_USE_ALLOCATOR=1 -DRNS_USE_TLSF=1 diff --git a/examples/udp_announce/main.cpp b/examples/udp_announce/main.cpp index 58832da4..eaee9a5d 100644 --- a/examples/udp_announce/main.cpp +++ b/examples/udp_announce/main.cpp @@ -267,7 +267,7 @@ void setup() { } #endif -#if defined(MEM_LOG) +#if defined(RNS_MEM_LOG) RNS::loglevel(RNS::LOG_MEM); #else //RNS::loglevel(RNS::LOG_WARNING); diff --git a/examples/udp_announce/platformio.ini b/examples/udp_announce/platformio.ini index 3d019e99..5971f590 100644 --- a/examples/udp_announce/platformio.ini +++ b/examples/udp_announce/platformio.ini @@ -20,7 +20,7 @@ build_type = debug build_flags = -DRNS_USE_ALLOCATOR=1 -DRNS_USE_TLSF=1 - ;-DMEM_LOG + ;-DRNS_MEM_LOG lib_deps = ArduinoJson@^6.21.3 https://github.com/attermann/Crypto.git diff --git a/platformio.ini b/platformio.ini index 4336cbd4..9460ae12 100644 --- a/platformio.ini +++ b/platformio.ini @@ -57,6 +57,10 @@ lib_deps = ${env.lib_deps} lib_compat_mode = off extra_scripts = pre:link_bz2.py +src_filter = + +<*> + - + - [env:native20] platform = native diff --git a/src/BLE/BLEFragmenter.cpp b/src/BLE/BLEFragmenter.cpp index ea761677..55b72b7d 100644 --- a/src/BLE/BLEFragmenter.cpp +++ b/src/BLE/BLEFragmenter.cpp @@ -45,8 +45,8 @@ std::vector BLEFragmenter::fragment(const Bytes& data, uint16_t sequence_ // Handle empty data case if (data.size() == 0) { - // Single empty END fragment - fragments.push_back(createFragment(Fragment::END, sequence_base, 1, Bytes())); + // Single empty LONE fragment + fragments.push_back(createFragment(Fragment::LONE, sequence_base, 1, Bytes())); return fragments; } @@ -69,8 +69,8 @@ std::vector BLEFragmenter::fragment(const Bytes& data, uint16_t sequence_ // Determine fragment type Fragment::Type type; if (total_fragments == 1) { - // Single fragment - use END type - type = Fragment::END; + // Single fragment - use LONE type + type = Fragment::LONE; } else if (i == 0) { // First of multiple fragments type = Fragment::START; @@ -133,7 +133,8 @@ bool BLEFragmenter::parseHeader(const Bytes& fragment, Fragment::Type& type, // Byte 0: Type uint8_t type_byte = ptr[0]; - if (type_byte != Fragment::START && + if (type_byte != Fragment::LONE && + type_byte != Fragment::START && type_byte != Fragment::CONTINUE && type_byte != Fragment::END) { return false; diff --git a/src/BLE/BLEPeerManager.cpp b/src/BLE/BLEPeerManager.cpp index e2dcac7b..7bfeb780 100644 --- a/src/BLE/BLEPeerManager.cpp +++ b/src/BLE/BLEPeerManager.cpp @@ -397,10 +397,18 @@ void BLEPeerManager::setPeerState(const Bytes& identifier, PeerState state) { } void BLEPeerManager::setPeerHandle(const Bytes& identifier, uint16_t conn_handle) { + // Validate handle is in range (ESP32 NimBLE uses 0-7) + if (conn_handle != 0xFFFF && conn_handle >= MAX_CONN_HANDLES) { + WARNING("BLEPeerManager: Rejecting invalid conn_handle=" + + std::to_string(conn_handle) + " (max=" + + std::to_string(MAX_CONN_HANDLES - 1) + ")"); + return; + } + PeerInfo* peer = findPeer(identifier); if (peer) { // Remove old handle mapping if exists - if (peer->conn_handle != 0xFFFF) { + if (peer->conn_handle != 0xFFFF && peer->conn_handle < MAX_CONN_HANDLES) { clearHandleToPeer(peer->conn_handle); } peer->conn_handle = conn_handle; @@ -586,6 +594,20 @@ void BLEPeerManager::cleanupStalePeers(double max_age) { } } } + + // Zombie detection: connected peers with no recent activity + for (size_t i = 0; i < PEERS_POOL_SIZE; i++) { + if (!_peers_by_identity_pool[i].in_use) continue; + + PeerInfo& peer = _peers_by_identity_pool[i].peer; + if (peer.isConnected() && peer.last_activity > 0) { + double idle = now - peer.last_activity; + if (idle > Timing::ZOMBIE_TIMEOUT) { + WARNING("BLEPeerManager: Zombie peer detected, marking for disconnect"); + peer.state = PeerState::DISCONNECTING; + } + } + } } //============================================================================= @@ -686,7 +708,13 @@ void BLEPeerManager::promoteToIdentityKeyed(const Bytes& mac_address, const Byte // Update handle mapping to point to new location if (identity_slot->peer.conn_handle != 0xFFFF) { - setHandleToPeer(identity_slot->peer.conn_handle, &identity_slot->peer); + if (identity_slot->peer.conn_handle < MAX_CONN_HANDLES) { + setHandleToPeer(identity_slot->peer.conn_handle, &identity_slot->peer); + } else { + WARNING("BLEPeerManager: Promoted peer has invalid conn_handle=" + + std::to_string(identity_slot->peer.conn_handle) + ", clearing"); + identity_slot->peer.conn_handle = 0xFFFF; + } } // Add MAC-to-identity mapping diff --git a/src/BLE/BLEPeerManager.h b/src/BLE/BLEPeerManager.h index d0b28673..6534c4a7 100644 --- a/src/BLE/BLEPeerManager.h +++ b/src/BLE/BLEPeerManager.h @@ -54,6 +54,10 @@ struct PeerInfo { uint8_t consecutive_failures = 0; double blacklisted_until = 0.0; + // Keepalive failure tracking + uint8_t consecutive_keepalive_failures = 0; + static constexpr uint8_t MAX_KEEPALIVE_FAILURES = 3; + // BLE connection handle (platform-specific) uint16_t conn_handle = 0xFFFF; diff --git a/src/BLE/BLEPlatform.h b/src/BLE/BLEPlatform.h index 97459999..db03b6f9 100644 --- a/src/BLE/BLEPlatform.h +++ b/src/BLE/BLEPlatform.h @@ -190,6 +190,15 @@ class IBLEPlatform { */ virtual bool write(uint16_t conn_handle, const Bytes& data, bool response = true) = 0; + /** + * @brief Write to a specific characteristic by handle + */ + virtual bool writeCharacteristic(uint16_t conn_handle, uint16_t char_handle, + const Bytes& data, bool response = true) { + (void)char_handle; + return write(conn_handle, data, response); + } + /** * @brief Read from a characteristic * diff --git a/src/BLE/BLEReassembler.cpp b/src/BLE/BLEReassembler.cpp index 2b2148a3..15efdcf5 100644 --- a/src/BLE/BLEReassembler.cpp +++ b/src/BLE/BLEReassembler.cpp @@ -95,6 +95,16 @@ bool BLEReassembler::processFragment(const Bytes& peer_identity, const Bytes& fr double now = Utilities::OS::time(); + // Handle LONE fragment - single complete message, no reassembly needed + if (type == Fragment::LONE) { + Bytes payload = BLEFragmenter::extractPayload(fragment); + TRACE("BLEReassembler: LONE fragment, delivering immediately"); + if (_reassembly_callback) { + _reassembly_callback(peer_identity, payload); + } + return true; + } + // Handle START fragment - begins a new reassembly if (type == Fragment::START) { // Clear any existing incomplete reassembly for this peer diff --git a/src/BLE/BLETypes.h b/src/BLE/BLETypes.h index ecfad9fc..3e8336da 100644 --- a/src/BLE/BLETypes.h +++ b/src/BLE/BLETypes.h @@ -52,10 +52,10 @@ namespace UUID { //============================================================================= namespace MTU { + static constexpr uint16_t ATT_OVERHEAD = 3; // ATT protocol header overhead static constexpr uint16_t REQUESTED = 517; // Request maximum MTU (BLE 5.0) static constexpr uint16_t MINIMUM = 23; // BLE 4.0 minimum MTU - static constexpr uint16_t INITIAL = 185; // Conservative default (BLE 4.2) - static constexpr uint16_t ATT_OVERHEAD = 3; // ATT protocol header overhead + static constexpr uint16_t INITIAL = 185 - ATT_OVERHEAD; // Conservative default (BLE 4.2), minus ATT overhead } //============================================================================= @@ -66,11 +66,13 @@ namespace Timing { static constexpr double KEEPALIVE_INTERVAL = 15.0; // Seconds between keepalives static constexpr double REASSEMBLY_TIMEOUT = 30.0; // Seconds to complete reassembly static constexpr double CONNECTION_TIMEOUT = 30.0; // Seconds to establish connection - static constexpr double HANDSHAKE_TIMEOUT = 10.0; // Seconds for identity exchange + static constexpr double HANDSHAKE_TIMEOUT = 30.0; // Seconds for identity exchange (match Columba) static constexpr double SCAN_INTERVAL = 5.0; // Seconds between scans static constexpr double PEER_TIMEOUT = 30.0; // Seconds before peer removal static constexpr double POST_MTU_DELAY = 0.15; // Seconds after MTU negotiation static constexpr double BLACKLIST_BASE_BACKOFF = 60.0; // Base backoff seconds + static constexpr double ZOMBIE_TIMEOUT = 45.0; // Seconds with no activity before force-disconnect + static constexpr double ADVERTISING_REFRESH_INTERVAL = 60.0; // Seconds between advertising refreshes } //============================================================================= @@ -111,9 +113,10 @@ namespace Fragment { static constexpr size_t HEADER_SIZE = 5; enum Type : uint8_t { + LONE = 0x00, // Single fragment (complete message) START = 0x01, // First fragment of multi-fragment message CONTINUE = 0x02, // Middle fragment - END = 0x03 // Last fragment (or single fragment) + END = 0x03 // Last fragment }; } diff --git a/src/Bytes.h b/src/Bytes.h index 1ed0fa2a..dde7b51d 100644 --- a/src/Bytes.h +++ b/src/Bytes.h @@ -97,14 +97,12 @@ MEM("Creating from data-move..."); MEMF("Bytes object created from data-move \"%s\", this: %lu, data: %lu", toString().c_str(), this, _data.get()); } // Construct from std::vector with standard allocator (for MsgPack interop) - // Only needed on Arduino where Data uses PSRAMAllocator (different from std::allocator) -#ifdef ARDUINO + // Needed because Data uses PSRAMAllocator (different from std::allocator) Bytes(const std::vector& stdvec) { MEM("Creating from std::vector..."); assign(stdvec.data(), stdvec.size()); MEMF("Bytes object created from std::vector \"%s\", this: %lu, data: %lu", toString().c_str(), this, _data.get()); } -#endif Bytes(const uint8_t* chunk, size_t size) { assign(chunk, size); MEMF("Bytes object created from chunk \"%s\", this: %lu, data: %lu", toString().c_str(), this, _data.get()); diff --git a/src/BytesPool.h b/src/BytesPool.h index b05767d3..4ff7be6f 100644 --- a/src/BytesPool.h +++ b/src/BytesPool.h @@ -14,10 +14,13 @@ * of destroying it. * * The pool has four tiers sized for Reticulum packet processing: - * - 64 bytes (512 slots): hashes (16-32 bytes), small fields - highest traffic - * - 256 bytes (24 slots): keys, small announces - * - 512 bytes (16 slots): standard packets (MTU=500 + margin) - * - 1024 bytes (16 slots): resource advertisements, large packets + * - 64 bytes (1024 slots): hashes (16-32 bytes), small fields - highest traffic + * - 256 bytes (16 slots): keys, small announces + * - 512 bytes (12 slots): standard packets (MTU=500 + margin) + * - 1024 bytes (12 slots): resource advertisements, large packets + * + * All storage arrays are dynamically allocated in PSRAM on ESP32 to avoid + * consuming internal RAM (BSS). Only the pointers (~32 bytes) stay in BSS. * * Thread-safe via FreeRTOS spinlock (ESP32) or std::mutex (native). * @@ -44,6 +47,7 @@ #if defined(ESP_PLATFORM) || defined(ARDUINO) #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" +#include #define BYTESPOOL_USE_SPINLOCK 1 #else // Native build - use std::mutex instead @@ -61,13 +65,15 @@ namespace BytesPoolConfig { static constexpr size_t TIER_MEDIUM = 512; // Standard packets static constexpr size_t TIER_LARGE = 1024; // Large packets, resource ads - // Slot counts per tier - tuned from runtime observation (2026-01-24) - // NOTE: Each slot uses ~16 bytes internal RAM for vector metadata + stack pointer - // 512 slots = ~8KB internal RAM overhead (conservative for busy networks) - static constexpr size_t TINY_SLOTS = 512; // High traffic tier (balanced for memory) - static constexpr size_t SMALL_SLOTS = 8; // Low traffic (peak 1) - static constexpr size_t MEDIUM_SLOTS = 8; // Rare (packets, keep headroom) - static constexpr size_t LARGE_SLOTS = 8; // Rare (resource ads, keep headroom) + // Slot counts per tier — tuned 2026-02-19 + // Storage arrays now live in PSRAM, so internal RAM cost is only pointers (~32B). + // Tiny tier for transient packet processing only. Known destinations now use + // fixed buffers (zero pool slots). 1024 provides ample headroom for packet + // hashes, Transport tables, and burst announce processing. + static constexpr size_t TINY_SLOTS = 1024; // Transient packet processing (hashes, keys, fields) + static constexpr size_t SMALL_SLOTS = 16; // Keys, small announces + static constexpr size_t MEDIUM_SLOTS = 12; // Standard packets + static constexpr size_t LARGE_SLOTS = 12; // Resource ads, large packets // Tier identifiers for deleter enum Tier : uint8_t { @@ -93,12 +99,12 @@ using PooledData = std::vector>; * - Repeated capacity reservation allocations * - shared_ptr control block allocations (via make_shared replacement) * - * Memory footprint (tuned 2026-01-24): - * - Tiny: 512 slots x 64 bytes = 32KB backing + ~8KB metadata (internal RAM) - * - Small: 8 slots x 256 bytes = 2KB backing + ~128B metadata - * - Medium: 8 slots x 512 bytes = 4KB backing + ~128B metadata - * - Large: 8 slots x 1024 bytes = 8KB backing + ~128B metadata - * - Total: ~46KB PSRAM backing + ~8.5KB internal RAM metadata + * Memory footprint (tuned 2026-02-19, all storage in PSRAM): + * - Tiny: 1024 slots x 64 bytes = 64KB backing + ~16KB metadata (PSRAM) + * - Small: 16 slots x 256 bytes = 4KB backing + ~256B metadata (PSRAM) + * - Medium: 12 slots x 512 bytes = 6KB backing + ~192B metadata (PSRAM) + * - Large: 12 slots x 1024 bytes = 12KB backing + ~192B metadata (PSRAM) + * - Total: ~103KB PSRAM, ~32 bytes internal RAM (pointers only) */ class BytesPool { public: @@ -272,8 +278,7 @@ class BytesPool { #if BYTESPOOL_USE_SPINLOCK portMUX_INITIALIZE(&_mux); #endif - // Pre-allocate all pool entries - // This is done at construction (startup) to front-load allocations + allocateStorage(); initializeTier(_tiny_storage, _tiny_stack, _tiny_count, BytesPoolConfig::TIER_TINY, BytesPoolConfig::TINY_SLOTS); initializeTier(_small_storage, _small_stack, _small_count, @@ -288,11 +293,54 @@ class BytesPool { BytesPool(const BytesPool&) = delete; BytesPool& operator=(const BytesPool&) = delete; + // Allocate storage and stack arrays in PSRAM (ESP32) or heap (native) + void allocateStorage() { +#if BYTESPOOL_USE_SPINLOCK + // ESP32: allocate in PSRAM to avoid consuming internal RAM (BSS) + #define POOL_ALLOC(ptr, type, count) do { \ + ptr = static_cast(heap_caps_aligned_alloc( \ + alignof(type), (count) * sizeof(type), \ + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); \ + if (!ptr) { \ + ptr = static_cast(heap_caps_aligned_alloc( \ + alignof(type), (count) * sizeof(type), \ + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); \ + if (ptr) WARNING("BytesPool: " #ptr " fell back to internal RAM"); \ + } \ + } while(0) + + POOL_ALLOC(_tiny_storage, PooledData, BytesPoolConfig::TINY_SLOTS); + POOL_ALLOC(_tiny_stack, PooledData*, BytesPoolConfig::TINY_SLOTS); + POOL_ALLOC(_small_storage, PooledData, BytesPoolConfig::SMALL_SLOTS); + POOL_ALLOC(_small_stack, PooledData*, BytesPoolConfig::SMALL_SLOTS); + POOL_ALLOC(_medium_storage, PooledData, BytesPoolConfig::MEDIUM_SLOTS); + POOL_ALLOC(_medium_stack, PooledData*, BytesPoolConfig::MEDIUM_SLOTS); + POOL_ALLOC(_large_storage, PooledData, BytesPoolConfig::LARGE_SLOTS); + POOL_ALLOC(_large_stack, PooledData*, BytesPoolConfig::LARGE_SLOTS); + #undef POOL_ALLOC +#else + // Native: use new[] + _tiny_storage = new PooledData[BytesPoolConfig::TINY_SLOTS]; + _tiny_stack = new PooledData*[BytesPoolConfig::TINY_SLOTS]; + _small_storage = new PooledData[BytesPoolConfig::SMALL_SLOTS]; + _small_stack = new PooledData*[BytesPoolConfig::SMALL_SLOTS]; + _medium_storage = new PooledData[BytesPoolConfig::MEDIUM_SLOTS]; + _medium_stack = new PooledData*[BytesPoolConfig::MEDIUM_SLOTS]; + _large_storage = new PooledData[BytesPoolConfig::LARGE_SLOTS]; + _large_stack = new PooledData*[BytesPoolConfig::LARGE_SLOTS]; +#endif + } + // Initialize a tier with pre-allocated vectors void initializeTier(PooledData* storage, PooledData** stack, size_t& count, size_t capacity, size_t slots) { + if (!storage || !stack) { + ERROR("BytesPool: allocation failed for tier, pool will be undersized"); + count = 0; + return; + } for (size_t i = 0; i < slots; i++) { - // Placement new to construct in storage array + // Placement new to construct in allocated storage new (&storage[i]) PooledData(); storage[i].reserve(capacity); stack[i] = &storage[i]; @@ -300,17 +348,17 @@ class BytesPool { count = slots; } - // Storage for pooled vectors (fixed arrays avoid dynamic allocation) - PooledData _tiny_storage[BytesPoolConfig::TINY_SLOTS]; - PooledData _small_storage[BytesPoolConfig::SMALL_SLOTS]; - PooledData _medium_storage[BytesPoolConfig::MEDIUM_SLOTS]; - PooledData _large_storage[BytesPoolConfig::LARGE_SLOTS]; - - // Stacks of available vectors (indices into storage arrays) - PooledData* _tiny_stack[BytesPoolConfig::TINY_SLOTS]; - PooledData* _small_stack[BytesPoolConfig::SMALL_SLOTS]; - PooledData* _medium_stack[BytesPoolConfig::MEDIUM_SLOTS]; - PooledData* _large_stack[BytesPoolConfig::LARGE_SLOTS]; + // Storage for pooled vectors — dynamically allocated (PSRAM on ESP32) + PooledData* _tiny_storage = nullptr; + PooledData* _small_storage = nullptr; + PooledData* _medium_storage = nullptr; + PooledData* _large_storage = nullptr; + + // Stacks of available vectors (pointers into storage arrays) + PooledData** _tiny_stack = nullptr; + PooledData** _small_stack = nullptr; + PooledData** _medium_stack = nullptr; + PooledData** _large_stack = nullptr; // Stack counts (how many available in each tier) size_t _tiny_count = 0; diff --git a/src/Identity.cpp b/src/Identity.cpp index e44896cc..ebfa9cf0 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -27,6 +27,9 @@ using namespace RNS::Utilities; // Pool for known destinations - allocated in PSRAM to free ~29KB internal RAM /*static*/ Identity::KnownDestinationSlot* Identity::_known_destinations_pool = nullptr; /*static*/ bool Identity::_saving_known_destinations = false; +/*static*/ void (*Identity::_persist_yield_callback)() = nullptr; +/*static*/ bool Identity::_known_destinations_dirty = false; +/*static*/ double Identity::_known_destinations_dirty_since = 0; /*static*/ uint16_t Identity::_known_destinations_maxsize = 2048; // Matches KNOWN_DESTINATIONS_SIZE // Initialize known destinations pool in PSRAM @@ -316,16 +319,19 @@ Can be used to load previously created and saved identities into Reticulum. slot->set_hash(destination_hash); slot->entry = IdentityEntry(OS::time(), packet_hash, public_key, app_data); should_save = true; - } else if (app_data && app_data.size() > 0 && slot->entry._app_data != app_data) { + } else if (app_data && app_data.size() > 0 && !slot->entry.app_data_equals(app_data)) { // Update existing with new app_data - slot->entry._app_data = app_data; + slot->entry.set_app_data(app_data); slot->entry._timestamp = OS::time(); should_save = true; } - // Persist to storage if changed + // Mark dirty — actual save deferred to periodic persist_data() call if (should_save) { - save_known_destinations(); + if (!_known_destinations_dirty) { + _known_destinations_dirty_since = OS::time(); + } + _known_destinations_dirty = true; } } } @@ -344,7 +350,7 @@ Recall identity for a destination hash. const IdentityEntry& identity_data = slot->entry; Identity identity(false); identity.load_public_key(identity_data.public_key_bytes()); - identity.app_data(identity_data._app_data); + identity.app_data(identity_data.app_data_bytes()); return identity; } else { @@ -374,7 +380,7 @@ Recall last heard app_data for a destination hash. if (slot != nullptr) { TRACE("Identity::recall_app_data: Found identity entry for destination " + destination_hash.toHex()); const IdentityEntry& identity_data = slot->entry; - return identity_data._app_data; + return identity_data.app_data_bytes(); } else { TRACE("Identity::recall_app_data: Unable to find identity entry for destination " + destination_hash.toHex()); @@ -408,13 +414,20 @@ Recall last heard app_data for a destination hash. double save_start = OS::time(); - size_t dest_count = known_destinations_count(); - DEBUG("Saving " + std::to_string(dest_count) + " known destinations to storage (binary)..."); + // Only save persistent destinations (contacts with message history). + // Network announces stay in RAM but don't need to survive reboots. + size_t persist_count = persistent_destinations_count(); + size_t total_count = known_destinations_count(); + INFO("Saving " + std::to_string(persist_count) + " persistent destinations (" + + std::to_string(total_count) + " total in pool) to storage..."); + + // Atomic save: write to temp file first, then rename. + // If a crash occurs mid-write, the original file is intact. + const char* temp_path = "/known_dst.tmp"; - // Open file for writing using OS filesystem abstraction - FileStream file = OS::open_file(storage_path, FileStream::MODE_WRITE); + FileStream file = OS::open_file(temp_path, FileStream::MODE_WRITE); if (!file) { - ERROR("Failed to open known destinations file for writing"); + ERROR("Failed to open temp file for writing known destinations"); _saving_known_destinations = false; return false; } @@ -422,15 +435,17 @@ Recall last heard app_data for a destination hash. // Write header: magic (4) + version (1) + count (2) = 7 bytes const uint8_t magic[4] = {'K', 'D', 'S', 'T'}; const uint8_t version = 1; - uint16_t count = static_cast(dest_count); + uint16_t count = static_cast(persist_count); file.write(magic, 4); file.write(&version, 1); file.write((const uint8_t*)&count, sizeof(uint16_t)); - // Write each entry directly from fixed arrays - no heap allocation! + // Write each persistent entry directly from fixed arrays - no heap allocation! + size_t entries_written = 0; for (size_t i = 0; i < KNOWN_DESTINATIONS_SIZE; ++i) { if (!_known_destinations_pool[i].in_use) continue; + if (!_known_destinations_pool[i].persist) continue; // Skip non-persistent const KnownDestinationSlot& slot = _known_destinations_pool[i]; // destination_hash: 16 bytes @@ -442,15 +457,28 @@ Recall last heard app_data for a destination hash. // public_key: 64 bytes file.write(slot.entry._public_key, PUBLIC_KEY_SIZE); // app_data_len: 2 bytes + app_data: variable - uint16_t app_data_len = static_cast(slot.entry._app_data.size()); + uint16_t app_data_len = static_cast(slot.entry._app_data_len); file.write((const uint8_t*)&app_data_len, sizeof(uint16_t)); if (app_data_len > 0) { - file.write(slot.entry._app_data.data(), app_data_len); + file.write(slot.entry._app_data, app_data_len); + } + + // Yield periodically to feed watchdog during slow flash I/O + if (_persist_yield_callback && (++entries_written % 5 == 0)) { + _persist_yield_callback(); } } file.close(); + // Atomic rename: delete old file, rename temp to final path + OS::remove_file(storage_path); + if (!OS::rename_file(temp_path, storage_path)) { + WARNING("Failed to rename temp file to known destinations, trying direct copy"); + // Fallback: temp file exists with valid data, next boot will find it + // or we can try again next persist cycle + } + std::string time_str; double save_time = OS::time() - save_start; if (save_time < 1) { @@ -460,7 +488,7 @@ Recall last heard app_data for a destination hash. time_str = std::to_string(OS::round(save_time, 1)) + " s"; } - DEBUG("Saved " + std::to_string(dest_count) + " known destinations in " + time_str); + DEBUG("Saved " + std::to_string(persist_count) + " known destinations in " + time_str); success = true; } @@ -481,6 +509,13 @@ Recall last heard app_data for a destination hash. // Binary format - much more memory efficient than JSON const char* storage_path = "/known_dst.bin"; + const char* temp_path = "/known_dst.tmp"; + + // If main file doesn't exist but temp does, a crash interrupted the rename + if (!OS::file_exists(storage_path) && OS::file_exists(temp_path)) { + INFO("Recovering known destinations from temp file (crash during save)"); + OS::rename_file(temp_path, storage_path); + } if (!OS::file_exists(storage_path)) { DEBUG("No known destinations file found, starting fresh"); @@ -502,8 +537,9 @@ Recall last heard app_data for a destination hash. if (file.readBytes((char*)magic, 4) != 4 || magic[0] != 'K' || magic[1] != 'D' || magic[2] != 'S' || magic[3] != 'T') { - WARNING("Invalid known destinations file magic"); + WARNING("Invalid known destinations file magic, deleting corrupt file"); file.close(); + OS::remove_file(storage_path); return; } @@ -519,7 +555,7 @@ Recall last heard app_data for a destination hash. return; } - DEBUG("Loading " + std::to_string(count) + " known destinations from storage (binary)..."); + INFO("Loading " + std::to_string(count) + " known destinations from storage (binary)..."); size_t loaded_count = 0; @@ -566,6 +602,7 @@ Recall last heard app_data for a destination hash. break; } slot->in_use = true; + slot->persist = true; // Loaded from disk = previously a contact slot->set_hash(dest_hash); slot->entry = IdentityEntry(timestamp, packet_hash, public_key, app_data); loaded_count++; @@ -574,7 +611,15 @@ Recall last heard app_data for a destination hash. file.close(); - DEBUG("Loaded " + std::to_string(loaded_count) + " known destinations from storage"); + INFO("Loaded " + std::to_string(loaded_count) + " known destinations from storage"); + // Count entries with app_data for debug + size_t with_app_data = 0; + for (size_t i = 0; i < KNOWN_DESTINATIONS_SIZE; ++i) { + if (_known_destinations_pool[i].in_use && _known_destinations_pool[i].entry._app_data_len > 0) { + with_app_data++; + } + } + INFO(" " + std::to_string(with_app_data) + " entries have app_data (display names)"); } catch (std::exception& e) { ERRORF("Error loading known destinations from disk: %s", e.what()); @@ -616,6 +661,12 @@ Recall last heard app_data for a destination hash. /*static*/ bool Identity::validate_announce(const Packet& packet) { try { if (packet.packet_type() == Type::Packet::ANNOUNCE) { + size_t min_announce_size = KEYSIZE/8 + NAME_HASH_LENGTH/8 + RANDOM_HASH_LENGTH/8 + SIGLENGTH/8; + if (packet.data().size() < min_announce_size) { + WARNING("Identity::validate_announce: packet too small (" + std::to_string(packet.data().size()) + " < " + std::to_string(min_announce_size) + "), dropping"); + return false; + } + Bytes destination_hash = packet.destination_hash(); //TRACE("Identity::validate_announce: destination_hash: " + packet.destination_hash().toHex()); Bytes public_key = packet.data().left(KEYSIZE/8); @@ -754,14 +805,62 @@ Recall last heard app_data for a destination hash. /*static*/ void Identity::persist_data() { if (!Transport::reticulum() || !Transport::reticulum().is_connected_to_shared_instance()) { - save_known_destinations(); + if (_known_destinations_dirty) { + INFO("Identity: Persisting " + std::to_string(known_destinations_count()) + " known destinations (dirty flag set)"); + if (save_known_destinations()) { + _known_destinations_dirty = false; + _known_destinations_dirty_since = 0; + } else { + ERROR("Identity: Failed to save known destinations!"); + } + } + } +} + +/*static*/ bool Identity::should_persist_data() { + // Persist if dirty for more than 60 seconds. + // Writing 100+ destinations to SPIFFS takes 20-50s with GC, so frequent + // persists cause severe fragmentation and main-loop stalls. 60s gives the + // flash controller time to recover between writes. Data survives reboots + // because recoverBLEStack() and exit_handler() force an immediate persist. + if (_known_destinations_dirty && _known_destinations_dirty_since > 0) { + double age = OS::time() - _known_destinations_dirty_since; + if (age >= 60.0) { + persist_data(); + return true; + } } + return false; } /*static*/ void Identity::exit_handler() { persist_data(); } +/*static*/ void Identity::mark_persistent(const Bytes& destination_hash) { + if (!_known_destinations_pool) return; + KnownDestinationSlot* slot = find_known_destination_slot(destination_hash); + if (slot && !slot->persist) { + slot->persist = true; + _known_destinations_dirty = true; + if (_known_destinations_dirty_since == 0) { + _known_destinations_dirty_since = OS::time(); + } + DEBUG("Identity: Marked " + destination_hash.toHex().substr(0, 8) + " as persistent contact"); + } +} + +/*static*/ size_t Identity::persistent_destinations_count() { + if (!_known_destinations_pool) return 0; + size_t count = 0; + for (size_t i = 0; i < KNOWN_DESTINATIONS_SIZE; ++i) { + if (_known_destinations_pool[i].in_use && _known_destinations_pool[i].persist) { + count++; + } + } + return count; +} + /* Encrypts information for the identity. diff --git a/src/Identity.h b/src/Identity.h index 843bb358..5194451e 100644 --- a/src/Identity.h +++ b/src/Identity.h @@ -26,18 +26,21 @@ namespace RNS { static constexpr size_t DEST_HASH_SIZE = 16; // RNS truncated hash static constexpr size_t PACKET_HASH_SIZE = 32; // SHA256 full hash static constexpr size_t PUBLIC_KEY_SIZE = 64; // Ed25519 + X25519 public keys + static constexpr size_t APP_DATA_MAX_SIZE = 128; // Max app_data (display names are 5-30 bytes) class IdentityEntry { public: - IdentityEntry() : _timestamp(0) { + IdentityEntry() : _timestamp(0), _app_data_len(0) { memset(_packet_hash, 0, PACKET_HASH_SIZE); memset(_public_key, 0, PUBLIC_KEY_SIZE); + memset(_app_data, 0, APP_DATA_MAX_SIZE); } IdentityEntry(double timestamp, const Bytes& packet_hash, const Bytes& public_key, const Bytes& app_data) - : _timestamp(timestamp), _app_data(app_data) + : _timestamp(timestamp), _app_data_len(0) { set_packet_hash(packet_hash); set_public_key(public_key); + set_app_data(app_data); } // Helper methods for fixed array access @@ -54,19 +57,40 @@ namespace RNS { if (len < PUBLIC_KEY_SIZE) memset(_public_key + len, 0, PUBLIC_KEY_SIZE - len); } + // app_data access — fixed buffer, zero BytesPool allocations + Bytes app_data_bytes() const { + if (_app_data_len == 0) return {Bytes::NONE}; + return Bytes(_app_data, _app_data_len); + } + void set_app_data(const Bytes& b) { + if (!b || b.size() == 0) { + _app_data_len = 0; + return; + } + _app_data_len = static_cast(std::min(b.size(), APP_DATA_MAX_SIZE)); + memcpy(_app_data, b.data(), _app_data_len); + } + bool app_data_equals(const Bytes& b) const { + if ((!b || b.size() == 0) && _app_data_len == 0) return true; + if (!b || b.size() != _app_data_len) return false; + return memcmp(_app_data, b.data(), _app_data_len) == 0; + } + public: double _timestamp = 0; uint8_t _packet_hash[PACKET_HASH_SIZE]; // Fixed array - no heap alloc - uint8_t _public_key[PUBLIC_KEY_SIZE]; // Fixed array - no heap alloc - Bytes _app_data; // Keep as Bytes - variable size, typically small or empty + uint8_t _public_key[PUBLIC_KEY_SIZE]; // Fixed array - no heap alloc + uint8_t _app_data[APP_DATA_MAX_SIZE]; // Fixed buffer - no BytesPool slot consumed + uint8_t _app_data_len = 0; }; // Fixed pool for known destinations (replaces std::map) - // Memory: 2048 slots × ~160 bytes = ~320KB in PSRAM - // Pool allocated in PSRAM. Zero-allocation culling allows larger pools. + // Memory: 2048 slots × ~250 bytes = ~500KB in PSRAM (includes 128-byte app_data buffer) + // Pool allocated in PSRAM. Zero BytesPool allocations per destination. static constexpr size_t KNOWN_DESTINATIONS_SIZE = 2048; struct KnownDestinationSlot { bool in_use = false; + bool persist = false; // Only save to flash if true (contact/conversation peer) uint8_t destination_hash[DEST_HASH_SIZE]; // Fixed array - no heap alloc IdentityEntry entry; @@ -83,6 +107,7 @@ namespace RNS { } void clear() { in_use = false; + persist = false; memset(destination_hash, 0, DEST_HASH_SIZE); entry = IdentityEntry(); } @@ -98,6 +123,8 @@ namespace RNS { static size_t known_destinations_count(); //static std::map _known_destinations; static bool _saving_known_destinations; + static bool _known_destinations_dirty; + static double _known_destinations_dirty_since; // When dirty flag was first set // CBA static uint16_t _known_destinations_maxsize; @@ -255,8 +282,22 @@ namespace RNS { static bool validate_announce(const Packet& packet); static void persist_data(); + static bool should_persist_data(); // Persist if dirty for >5s static void exit_handler(); + // Yield callback — called periodically during long persistence operations + // (e.g., writing 50+ known destinations to flash). Platform code should + // set this to feed the watchdog timer and/or yield the CPU. + static void (*_persist_yield_callback)(); + static void set_persist_yield_callback(void (*cb)()) { _persist_yield_callback = cb; } + + // Mark a known destination for persistence. Only persistent destinations + // are written to flash — the rest stay in RAM for routing but don't + // survive reboots. Call this when a message is sent to or received from + // a destination (i.e., it's a real contact, not just a network announce). + static void mark_persistent(const Bytes& destination_hash); + static size_t persistent_destinations_count(); + // getters/setters inline const Bytes& encryptionPrivateKey() const { assert(_object); return _object->_prv_bytes; } inline const Bytes& signingPrivateKey() const { assert(_object); return _object->_sig_prv_bytes; } diff --git a/src/Instrumentation/MemoryMonitor.cpp b/src/Instrumentation/MemoryMonitor.cpp index bf031680..5bd665dc 100644 --- a/src/Instrumentation/MemoryMonitor.cpp +++ b/src/Instrumentation/MemoryMonitor.cpp @@ -38,6 +38,7 @@ static size_t _task_count = 0; // Static member initialization TimerHandle_t MemoryMonitor::_timer = nullptr; bool MemoryMonitor::_verbose = false; +volatile bool MemoryMonitor::_pending = false; // Static buffer for log formatting (avoid stack allocation in callbacks) static char _log_buffer[256]; @@ -152,8 +153,9 @@ void MemoryMonitor::logNow() { } -void MemoryMonitor::timerCallback(TimerHandle_t timer) { - (void)timer; // Unused parameter +void MemoryMonitor::poll() { + if (!_pending) return; + _pending = false; logHeapStats(); if (_task_count > 0) { @@ -162,6 +164,14 @@ void MemoryMonitor::timerCallback(TimerHandle_t timer) { } +void MemoryMonitor::timerCallback(TimerHandle_t timer) { + (void)timer; + // Only set flag — heavy logging is done in poll() on the main loop stack + // to avoid overflowing the small FreeRTOS timer task stack (3120 bytes) + _pending = true; +} + + void MemoryMonitor::logHeapStats() { // Internal RAM statistics (critical for stability) size_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); diff --git a/src/Instrumentation/MemoryMonitor.h b/src/Instrumentation/MemoryMonitor.h index 44578ea3..ed1918ce 100644 --- a/src/Instrumentation/MemoryMonitor.h +++ b/src/Instrumentation/MemoryMonitor.h @@ -91,11 +91,21 @@ class MemoryMonitor { */ static void logNow(); + /** + * Poll for pending log output (call from main loop) + * + * The timer callback only sets a flag; this method does the actual + * logging on the caller's stack (main loop) to avoid overflowing + * the small FreeRTOS timer task stack. + */ + static void poll(); + private: /** * FreeRTOS timer callback * * Called by the timer daemon task at each interval. + * Only sets _pending flag — actual work is done in poll(). */ static void timerCallback(TimerHandle_t timer); @@ -112,6 +122,7 @@ class MemoryMonitor { // Static members static TimerHandle_t _timer; static bool _verbose; + static volatile bool _pending; }; }} // namespace RNS::Instrumentation @@ -121,6 +132,7 @@ class MemoryMonitor { #define MEMORY_MONITOR_REGISTER_TASK(handle, name) RNS::Instrumentation::MemoryMonitor::registerTask(handle, name) #define MEMORY_MONITOR_UNREGISTER_TASK(handle) RNS::Instrumentation::MemoryMonitor::unregisterTask(handle) #define MEMORY_MONITOR_LOG_NOW() RNS::Instrumentation::MemoryMonitor::logNow() +#define MEMORY_MONITOR_POLL() RNS::Instrumentation::MemoryMonitor::poll() #define MEMORY_MONITOR_STOP() RNS::Instrumentation::MemoryMonitor::stop() #else // MEMORY_INSTRUMENTATION_ENABLED not defined @@ -130,6 +142,7 @@ class MemoryMonitor { #define MEMORY_MONITOR_REGISTER_TASK(handle, name) ((void)0) #define MEMORY_MONITOR_UNREGISTER_TASK(handle) ((void)0) #define MEMORY_MONITOR_LOG_NOW() ((void)0) +#define MEMORY_MONITOR_POLL() ((void)0) #define MEMORY_MONITOR_STOP() ((void)0) #endif // MEMORY_INSTRUMENTATION_ENABLED diff --git a/src/LXMF/LXMRouter.cpp b/src/LXMF/LXMRouter.cpp index 2b4ab8a5..e9255676 100644 --- a/src/LXMF/LXMRouter.cpp +++ b/src/LXMF/LXMRouter.cpp @@ -1,5 +1,4 @@ #include "LXMRouter.h" -#include "PropagationNodeManager.h" #include "../Log.h" #include "../Utilities/OS.h" #include "../Packet.h" @@ -8,6 +7,12 @@ #include +#ifdef ARDUINO +#include +#include +#include +#endif + using namespace LXMF; using namespace RNS; @@ -56,8 +61,9 @@ static RouterRegistrySlot* find_empty_router_registry_slot() { return nullptr; } -// Outbound resources fixed pool (zero heap fragmentation) -// Fixed arrays eliminate ~0.8KB Bytes metadata overhead (16 slots × 2 Bytes × 24 bytes) +// Outbound DIRECT delivery resources fixed pool (zero heap fragmentation) +// Tracks resource transfers for DIRECT delivery via links to recipients. +// Separate from PropResourceSlot (in header) which tracks PROPAGATED delivery resources. static constexpr size_t OUTBOUND_RESOURCES_SIZE = 16; static constexpr size_t OUTBOUND_HASH_SIZE = 32; // SHA256 hash size struct OutboundResourceSlot { @@ -106,6 +112,101 @@ static OutboundResourceSlot* find_empty_outbound_resource_slot() { return nullptr; } +namespace { +const char* method_name(LXMF::Type::Message::Method method) { + switch (method) { + case LXMF::Type::Message::OPPORTUNISTIC: return "OPPORTUNISTIC"; + case LXMF::Type::Message::DIRECT: return "DIRECT"; + case LXMF::Type::Message::PROPAGATED: return "PROPAGATED"; + case LXMF::Type::Message::PAPER: return "PAPER"; + default: return "UNKNOWN"; + } +} + +const char* state_name(LXMF::Type::Message::State state) { + switch (state) { + case LXMF::Type::Message::GENERATING: return "GENERATING"; + case LXMF::Type::Message::OUTBOUND: return "OUTBOUND"; + case LXMF::Type::Message::SENDING: return "SENDING"; + case LXMF::Type::Message::SENT: return "SENT"; + case LXMF::Type::Message::DELIVERED: return "DELIVERED"; + case LXMF::Type::Message::REJECTED: return "REJECTED"; + case LXMF::Type::Message::CANCELLED: return "CANCELLED"; + case LXMF::Type::Message::FAILED: return "FAILED"; + default: return "UNKNOWN"; + } +} + +void log_state_transition(LXMessage& message, LXMF::Type::Message::State new_state, const std::string& reason) { + LXMF::Type::Message::State old_state = message.state(); + if (old_state != new_state) { + std::string msg = "LXMF state " + std::string(state_name(old_state)) + + " -> " + state_name(new_state) + + " for " + message.hash().toHex().substr(0, 16) + "..."; + if (!reason.empty()) { + msg += " (" + reason + ")"; + } + if (new_state == LXMF::Type::Message::FAILED) { + WARNING(msg); + } else { + INFO(msg); + } + } else if (!reason.empty()) { + DEBUG("LXMF state " + std::string(state_name(new_state)) + " unchanged (" + reason + ")"); + } + message.state(new_state); +} + +#ifdef ARDUINO +struct PropagationStampJob { + volatile bool queued = false; + volatile bool running = false; + volatile bool completed = false; + volatile bool failed = false; + uint8_t target_cost = 0; + LXMF::LXMRouter* router = nullptr; + LXMF::LXMessage* message = nullptr; + uint8_t message_hash[32] = {0}; +}; + +static PropagationStampJob _propagation_stamp_job; +static TaskHandle_t _propagation_stamp_worker = nullptr; + +static bool stamp_job_matches_hash(const Bytes& hash) { + return hash.size() == sizeof(_propagation_stamp_job.message_hash) && + memcmp(_propagation_stamp_job.message_hash, hash.data(), sizeof(_propagation_stamp_job.message_hash)) == 0; +} + +static void propagation_stamp_worker_task(void* param) { + (void)param; + esp_task_wdt_add(NULL); + + for (;;) { + if (_propagation_stamp_job.queued && !_propagation_stamp_job.running) { + _propagation_stamp_job.running = true; + _propagation_stamp_job.completed = false; + _propagation_stamp_job.failed = false; + + LXMF::LXMessage* message = _propagation_stamp_job.message; + uint8_t target_cost = _propagation_stamp_job.target_cost; + Bytes stamp; + + if (message) { + stamp = message->generate_propagation_stamp(target_cost); + } + + _propagation_stamp_job.failed = (message == nullptr || stamp.size() == 0); + _propagation_stamp_job.completed = !_propagation_stamp_job.failed; + _propagation_stamp_job.queued = false; + _propagation_stamp_job.running = false; + } + + vTaskDelay(pdMS_TO_TICKS(2)); + } +} +#endif +} // namespace + // Static pending proofs pool definition LXMRouter::PendingProofSlot LXMRouter::_pending_proofs_pool[LXMRouter::PENDING_PROOFS_SIZE]; @@ -207,6 +308,16 @@ static void static_packet_callback(const Bytes& data, const Packet& packet) { } } +// Static packet callback for link (uses link destination hash for router lookup) +static void static_link_packet_callback(const Bytes& data, const Packet& packet) { + // For link packets, destination_hash() is the link_id, not the delivery destination. + // Look up the router via the link's destination hash instead. + RouterRegistrySlot* slot = find_router_registry_slot(packet.link().destination().hash()); + if (slot) { + slot->router->on_packet(data, packet); + } +} + // Static link callbacks static void static_link_established_callback(Link& link) { // Find router that owns this link destination @@ -285,52 +396,24 @@ static void static_outbound_resource_concluded(const Resource& resource) { // Static proof callback - called when delivery proof is received void LXMRouter::static_proof_callback(const PacketReceipt& receipt) { - DEBUG(">>> PROOF CALLBACK ENTRY"); -#ifdef ARDUINO - Serial.flush(); -#endif - - // Get packet hash from receipt - DEBUG(">>> Getting packet hash from receipt"); -#ifdef ARDUINO - Serial.flush(); -#endif Bytes packet_hash = receipt.hash(); char buf[128]; - DEBUG(">>> Looking up pending proof slot"); -#ifdef ARDUINO - Serial.flush(); -#endif - // Look up message hash for this packet PendingProofSlot* slot = find_pending_proof_slot(packet_hash); if (slot) { - DEBUG(">>> Found slot, getting message hash"); -#ifdef ARDUINO - Serial.flush(); -#endif Bytes message_hash = slot->message_hash_bytes(); snprintf(buf, sizeof(buf), "Delivery proof received for message %.16s...", message_hash.toHex().c_str()); INFO(buf); -#ifdef ARDUINO - Serial.flush(); -#endif // Track notified routers to avoid duplicates (max ROUTER_REGISTRY_SIZE) LXMRouter* notified_routers[ROUTER_REGISTRY_SIZE]; size_t notified_count = 0; - DEBUG(">>> Iterating router registry"); -#ifdef ARDUINO - Serial.flush(); -#endif - // Find the router that sent this message and call its delivered callback for (size_t i = 0; i < ROUTER_REGISTRY_SIZE; i++) { if (_router_registry_pool[i].in_use) { LXMRouter* router = _router_registry_pool[i].router; - // Check if already notified bool already_notified = false; for (size_t j = 0; j < notified_count; j++) { if (notified_routers[j] == router) { @@ -339,48 +422,22 @@ void LXMRouter::static_proof_callback(const PacketReceipt& receipt) { } } if (!already_notified && router && router->_delivered_callback) { - DEBUGF(">>> Calling delivered callback for router %zu", i); -#ifdef ARDUINO - Serial.flush(); -#endif notified_routers[notified_count++] = router; // Create a minimal message with just the hash for the callback - // The callback can look up full message from storage if needed Bytes empty_hash; LXMessage msg(empty_hash, empty_hash); msg.hash(message_hash); msg.state(Type::Message::DELIVERED); - DEBUG(">>> About to invoke callback"); -#ifdef ARDUINO - Serial.flush(); -#endif router->_delivered_callback(msg); - DEBUG(">>> Callback returned"); -#ifdef ARDUINO - Serial.flush(); -#endif } } } - // Remove from pending proofs - DEBUG(">>> Clearing slot"); -#ifdef ARDUINO - Serial.flush(); -#endif slot->clear(); - DEBUG(">>> Slot cleared"); -#ifdef ARDUINO - Serial.flush(); -#endif } else { snprintf(buf, sizeof(buf), "Received proof for unknown packet: %.16s...", packet_hash.toHex().c_str()); DEBUG(buf); } - DEBUG(">>> PROOF CALLBACK EXIT"); -#ifdef ARDUINO - Serial.flush(); -#endif } // Constructor @@ -469,6 +526,16 @@ LXMessage* LXMRouter::pending_outbound_front() { return &_pending_outbound_pool[_pending_outbound_tail]; } +LXMessage* LXMRouter::pending_outbound_find(const Bytes& hash) { + for (size_t i = 0; i < _pending_outbound_count; i++) { + size_t idx = (_pending_outbound_tail + i) % PENDING_OUTBOUND_SIZE; + if (_pending_outbound_pool[idx].hash() == hash) { + return &_pending_outbound_pool[idx]; + } + } + return nullptr; +} + bool LXMRouter::pending_outbound_pop(LXMessage& msg) { if (_pending_outbound_count == 0) return false; msg = _pending_outbound_pool[_pending_outbound_tail]; @@ -529,6 +596,223 @@ bool LXMRouter::failed_outbound_pop(LXMessage& msg) { return true; } +LXMRouter::OutboundContextSlot* LXMRouter::find_outbound_context_slot(const Bytes& hash) { + for (size_t i = 0; i < OUTBOUND_CONTEXTS_SIZE; i++) { + if (_outbound_contexts_pool[i].in_use && _outbound_contexts_pool[i].message_hash_equals(hash)) { + return &_outbound_contexts_pool[i]; + } + } + return nullptr; +} + +LXMRouter::OutboundContextSlot* LXMRouter::find_empty_outbound_context_slot() { + for (size_t i = 0; i < OUTBOUND_CONTEXTS_SIZE; i++) { + if (!_outbound_contexts_pool[i].in_use) { + return &_outbound_contexts_pool[i]; + } + } + return nullptr; +} + +LXMRouter::OutboundContextSlot* LXMRouter::get_or_create_outbound_context(const Bytes& hash) { + OutboundContextSlot* slot = find_outbound_context_slot(hash); + if (slot) { + return slot; + } + + slot = find_empty_outbound_context_slot(); + if (!slot) { + return nullptr; + } + + slot->in_use = true; + slot->set_message_hash(hash); + return slot; +} + +void LXMRouter::clear_outbound_context(const Bytes& hash) { + OutboundContextSlot* slot = find_outbound_context_slot(hash); + if (slot) { + slot->clear(); + } + +#ifdef ARDUINO + if ((_propagation_stamp_job.queued || _propagation_stamp_job.running) && stamp_job_matches_hash(hash)) { + if (!_propagation_stamp_job.running) { + _propagation_stamp_job.queued = false; + _propagation_stamp_job.completed = false; + _propagation_stamp_job.failed = false; + _propagation_stamp_job.message = nullptr; + _propagation_stamp_job.router = nullptr; + memset(_propagation_stamp_job.message_hash, 0, sizeof(_propagation_stamp_job.message_hash)); + } + } +#endif +} + +void LXMRouter::schedule_outbound_retry(LXMessage& message, double delay, OutboundStage stage, const std::string& reason) { + OutboundContextSlot* slot = get_or_create_outbound_context(message.hash()); + if (slot) { + slot->next_attempt_time = Utilities::OS::time() + delay; + slot->stage = stage; + } + if (message.state() != Type::Message::OUTBOUND) { + log_state_transition(message, Type::Message::OUTBOUND, reason); + } else { + INFO(reason); + } +} + +void LXMRouter::update_pending_message_state(const Bytes& message_hash, Type::Message::State state, const std::string& reason) { + LXMessage* message = pending_outbound_find(message_hash); + if (!message) { + return; + } + + log_state_transition(*message, state, reason); + if (state == Type::Message::DELIVERED || state == Type::Message::SENT || state == Type::Message::FAILED) { + OutboundContextSlot* slot = find_outbound_context_slot(message_hash); + if (slot) { + slot->next_attempt_time = 0.0; + slot->stage = OutboundStage::NONE; + slot->propagation_stamp_pending = false; + slot->propagation_stamp_failed = false; + } + } +} + +void LXMRouter::notify_sent_for_message_hash(const Bytes& message_hash, Type::Message::Method method) { + if (_sent_callback) { + Bytes empty_hash; + LXMessage msg(empty_hash, empty_hash); + msg.hash(message_hash); + msg.state(Type::Message::SENT); + msg.set_method(method); + _sent_callback(msg); + } +} + +void LXMRouter::notify_delivered_for_message_hash(const Bytes& message_hash) { + if (_delivered_callback) { + Bytes empty_hash; + LXMessage msg(empty_hash, empty_hash); + msg.hash(message_hash); + msg.state(Type::Message::DELIVERED); + _delivered_callback(msg); + } +} + +void LXMRouter::notify_failed_for_message_hash(const Bytes& message_hash) { + if (_failed_callback) { + Bytes empty_hash; + LXMessage msg(empty_hash, empty_hash); + msg.hash(message_hash); + msg.state(Type::Message::FAILED); + _failed_callback(msg); + } +} + +bool LXMRouter::path_supports_opportunistic(const Bytes& destination_hash, bool avoid_auto_interface_path) const { + if (!Transport::has_path(destination_hash)) { + return false; + } + + Interface next_hop_interface = Transport::next_hop_interface(destination_hash); + if (!next_hop_interface) { + return true; + } + + std::string if_name = next_hop_interface.toString(); + if (avoid_auto_interface_path && if_name.find("AutoInterface[") != std::string::npos) { + DEBUG(" Next hop is AutoInterface; suppressing opportunistic routing for this destination"); + return false; + } + + return true; +} + +bool LXMRouter::ensure_stamp_worker_started() { +#ifdef ARDUINO + if (_propagation_stamp_worker != nullptr) { + return true; + } + + BaseType_t result = xTaskCreatePinnedToCore( + propagation_stamp_worker_task, + "lxmf_stamp", + 8192, + nullptr, + 1, + &_propagation_stamp_worker, + 1 + ); + if (result != pdPASS) { + ERROR("Failed to start propagation stamp worker task"); + _propagation_stamp_worker = nullptr; + return false; + } + return true; +#else + return true; +#endif +} + +bool LXMRouter::queue_propagation_stamp_job(LXMessage& message) { + OutboundContextSlot* slot = get_or_create_outbound_context(message.hash()); + if (!slot) { + ERROR("No outbound context slot available for propagation stamp job"); + return false; + } + +#ifdef ARDUINO + if (!ensure_stamp_worker_started()) { + return false; + } + + if (_propagation_stamp_job.running || _propagation_stamp_job.queued) { + return stamp_job_matches_hash(message.hash()); + } + + slot->propagation_stamp_pending = true; + slot->propagation_stamp_failed = false; + slot->stage = OutboundStage::WAITING_PROP_STAMP; + + _propagation_stamp_job.target_cost = _outbound_propagation_stamp_cost; + _propagation_stamp_job.router = this; + _propagation_stamp_job.message = &message; + memcpy(_propagation_stamp_job.message_hash, message.hash().data(), sizeof(_propagation_stamp_job.message_hash)); + _propagation_stamp_job.failed = false; + _propagation_stamp_job.completed = false; + _propagation_stamp_job.queued = true; + + INFO(" Queued propagation stamp generation on background worker"); + return true; +#else + Bytes stamp = message.generate_propagation_stamp(_outbound_propagation_stamp_cost); + slot->propagation_stamp_pending = false; + slot->propagation_stamp_failed = (stamp.size() == 0); + return !slot->propagation_stamp_failed; +#endif +} + +void LXMRouter::nudge_propagation_sync(const std::string& reason) { + if (_outbound_propagation_node.size() == 0) { + return; + } + + double now = Utilities::OS::time(); + if (now - _last_propagation_sync_nudge < PROPAGATION_SYNC_NUDGE_INTERVAL) { + return; + } + if (!(_sync_state == PR_IDLE || _sync_state == PR_COMPLETE || _sync_state == PR_FAILED)) { + return; + } + + _last_propagation_sync_nudge = now; + INFO("Nudging propagation sync: " + reason); + request_messages_from_propagation_node(); +} + // ============== End Circular Buffer Helpers ============== // Register callbacks @@ -564,16 +848,16 @@ void LXMRouter::handle_outbound(LXMessage& message) { // Pack the message message.pack(); - // Check if message fits in a single packet - use OPPORTUNISTIC if so - // OPPORTUNISTIC is simpler (no link needed) and works when identity is known - if (message.packed_size() <= Type::Constants::ENCRYPTED_PACKET_MDU) { - INFO(" Message fits in single packet, will use OPPORTUNISTIC delivery"); + // Check if message fits in a single LoRa packet - use OPPORTUNISTIC if so + // Use LORA_ENCRYPTED_PACKET_MDU (159) to ensure packet fits within LoRa wire MTU (255) + if (message.packed_size() <= Type::Constants::LORA_ENCRYPTED_PACKET_MDU) { + INFO(" Message fits in single LoRa packet, will use OPPORTUNISTIC delivery"); } else { - INFO(" Message too large for single packet, will use DIRECT (link) delivery"); + INFO(" Message too large for single LoRa packet, will use DIRECT (link) delivery"); } // Set state to outbound - message.state(Type::Message::OUTBOUND); + log_state_transition(message, Type::Message::OUTBOUND, "queued for outbound routing"); // Add to pending queue pending_outbound_push(message); @@ -588,63 +872,207 @@ void LXMRouter::process_outbound() { return; } - // Check backoff timer - don't process if we're in retry delay double now = Utilities::OS::time(); - if (now < _next_outbound_process_time) { - return; // Wait until retry delay expires - } - - // Process one message per call to avoid blocking LXMessage* message_ptr = pending_outbound_front(); if (!message_ptr) return; LXMessage& message = *message_ptr; + OutboundContextSlot* context = get_or_create_outbound_context(message.hash()); char buf[128]; + if (message.state() == Type::Message::DELIVERED) { + INFO("Delivery confirmed, removing message from outbound queue"); + clear_outbound_context(message.hash()); + LXMessage dummy; + pending_outbound_pop(dummy); + return; + } + + if (message.method() == Type::Message::PROPAGATED && message.state() == Type::Message::SENT) { + INFO("Propagation transfer completed, removing message from outbound queue"); + clear_outbound_context(message.hash()); + LXMessage dummy; + pending_outbound_pop(dummy); + return; + } + + if (context && now < context->next_attempt_time) { + return; + } + + if (context && context->stage == OutboundStage::WAITING_PROP_STAMP && context->propagation_stamp_pending) { + return; + } + snprintf(buf, sizeof(buf), "Processing outbound message to %s", message.destination_hash().toHex().c_str()); DEBUG(buf); try { + auto fail_message = [&](const std::string& reason) { + log_state_transition(message, Type::Message::FAILED, reason); + clear_outbound_context(message.hash()); + if (_failed_callback) { + _failed_callback(message); + } + failed_outbound_push(message); + LXMessage dummy; + pending_outbound_pop(dummy); + }; + + auto retry_later = [&](double delay, OutboundStage stage, const std::string& reason) { + schedule_outbound_retry(message, delay, stage, reason); + }; + + auto attempt_propagated = [&](const std::string& reason) -> bool { + if (_outbound_propagation_node.size() == 0) { + fail_message("Propagation fallback requested but no propagation node is selected"); + return true; + } + + INFO(" Falling back to PROPAGATED delivery: " + reason); + if (context && !context->using_propagated_retry_budget) { + context->using_propagated_retry_budget = true; + context->propagation_attempts = 0; + INFO(" Resetting retry budget for PROPAGATED delivery"); + } + message.set_method(Type::Message::PROPAGATED); + + send_propagated(message); + return true; + }; + + auto using_propagated_budget = [&]() -> bool { + return context && context->using_propagated_retry_budget && message.method() == Type::Message::PROPAGATED; + }; + + auto current_attempts = [&]() -> int { + return using_propagated_budget() ? context->propagation_attempts : message.delivery_attempts(); + }; + + auto current_attempt_limit = [&]() -> int { + return using_propagated_budget() ? MAX_PROPAGATION_DELIVERY_ATTEMPTS : MAX_DELIVERY_ATTEMPTS; + }; + + auto increment_attempts = [&]() { + if (using_propagated_budget()) { + context->propagation_attempts++; + } else { + message.increment_delivery_attempts(); + } + }; + + if (message.state() == Type::Message::SENDING) { + if (context && context->stage == OutboundStage::DIRECT_TRANSFER) { + if (context->next_attempt_time > 0.0 && now >= context->next_attempt_time) { + schedule_outbound_retry(message, OUTBOUND_RETRY_DELAY, OutboundStage::WAITING_DIRECT_LINK, + "Direct delivery timed out waiting for proof/resource completion"); + } + return; + } + if (context && context->stage == OutboundStage::PROP_TRANSFER) { + if (context->next_attempt_time > 0.0 && now >= context->next_attempt_time) { + schedule_outbound_retry(message, OUTBOUND_RETRY_DELAY, OutboundStage::WAITING_PROP_LINK, + "Propagation transfer timed out waiting for node confirmation"); + } + return; + } + if (context && context->stage == OutboundStage::WAITING_PROP_STAMP) { + DEBUG(" Waiting for propagation stamp worker"); + return; + } + } + + // Check max delivery attempts + if (current_attempts() >= current_attempt_limit()) { + fail_message("Max delivery attempts reached"); + return; + } + increment_attempts(); + // If propagation-only mode is enabled, send via propagation node if (_propagation_only) { DEBUG(" Using PROPAGATED delivery (propagation-only mode)"); message.set_method(Type::Message::PROPAGATED); - if (send_propagated(message)) { - INFO("Message sent via PROPAGATED delivery"); - if (_sent_callback) { - _sent_callback(message); - } - LXMessage dummy; - pending_outbound_pop(dummy); - } else { - // Propagation not ready yet - wait and retry - DEBUG(" Propagation delivery not ready, will retry..."); - _next_outbound_process_time = now + OUTBOUND_RETRY_DELAY; + if (_outbound_propagation_node.size() == 0) { + fail_message("Propagation-only mode is enabled but no propagation node is configured"); + return; } + send_propagated(message); return; } - // Determine delivery method based on message size - bool use_opportunistic = (message.packed_size() <= Type::Constants::ENCRYPTED_PACKET_MDU); + bool has_dest_identity = Identity::recall(message.destination_hash()) ? true : false; + bool has_dest_path = Transport::has_path(message.destination_hash()); + if (context && message.delivery_attempts() == 1 && !has_dest_path && _fallback_to_propagation) { + context->avoid_auto_interface_path = true; + } + bool has_routable_path = has_dest_path; + if (context && context->avoid_auto_interface_path) { + has_routable_path = path_supports_opportunistic(message.destination_hash(), true); + } + bool has_opportunistic_path = path_supports_opportunistic( + message.destination_hash(), + context ? context->avoid_auto_interface_path : false + ); + bool has_prop_node = _outbound_propagation_node.size() > 0; - if (use_opportunistic) { - // OPPORTUNISTIC delivery - send as single encrypted packet - DEBUG(" Using OPPORTUNISTIC delivery (single packet)"); + // Determine delivery method based on message size, but only allow + // OPPORTUNISTIC when a concrete path exists to the destination. + bool opportunistic_size_ok; + if (message.method() == Type::Message::OPPORTUNISTIC) { + opportunistic_size_ok = (message.packed_size() <= Type::Constants::ENCRYPTED_PACKET_MDU); + } else { + opportunistic_size_ok = (message.packed_size() <= Type::Constants::LORA_ENCRYPTED_PACKET_MDU); + } - // Check if we have a path to the destination - if (!Transport::has_path(message.destination_hash())) { - // Request path from network - INFO(" No path to destination, requesting..."); - Transport::request_path(message.destination_hash()); - _next_outbound_process_time = now + PATH_REQUEST_WAIT; - return; + bool opportunistic_allowed = opportunistic_size_ok && has_opportunistic_path; + Type::Message::Method route_method = message.method() == Type::Message::PROPAGATED ? + Type::Message::PROPAGATED : + (opportunistic_allowed ? Type::Message::OPPORTUNISTIC : Type::Message::DIRECT); + if (message.method() != Type::Message::PROPAGATED) { + message.set_method(route_method); + } + + snprintf(buf, sizeof(buf), + " attempt=%d/%d method=%s packed=%zu identity=%s path=%s opp_path=%s prop=%s fallback=%s", + current_attempts(), + current_attempt_limit(), + method_name(route_method), + message.packed_size(), + has_dest_identity ? "yes" : "no", + has_dest_path ? "yes" : "no", + has_opportunistic_path ? "yes" : "no", + has_prop_node ? "yes" : "no", + _fallback_to_propagation ? "on" : "off"); + INFO(buf); + + if (message.method() == Type::Message::PROPAGATED) { + attempt_propagated("message already marked for propagated delivery"); + return; + } + + if (opportunistic_size_ok && !has_opportunistic_path) { + if (has_dest_path) { + INFO(" Opportunistic delivery skipped because only a non-opportunistic path is available"); + } else { + INFO(" Pathless OPPORTUNISTIC delivery skipped; waiting for path or fallback"); } + } - // Try to recall the destination identity + if (message.method() == Type::Message::OPPORTUNISTIC) { + // OPPORTUNISTIC delivery - send as single encrypted packet + DEBUG(" Using OPPORTUNISTIC delivery (single packet)"); + + // Try to recall the destination identity (needed to encrypt the packet) Identity dest_identity = Identity::recall(message.destination_hash()); if (!dest_identity) { - // Path exists but identity not cached yet - wait for announce - INFO(" Path exists but identity not known, waiting for announce..."); - _next_outbound_process_time = now + OUTBOUND_RETRY_DELAY; + // Identity not known - request path which may trigger an announce + INFO(" Destination identity not known, requesting path..."); + Transport::request_path(message.destination_hash()); + if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) { + attempt_propagated("destination identity remained unknown after opportunistic retries"); + return; + } + retry_later(PATH_REQUEST_WAIT, OutboundStage::WAITING_PATH, " Waiting for destination identity/path after request"); return; } @@ -661,27 +1089,31 @@ void LXMRouter::process_outbound() { LXMessage dummy; pending_outbound_pop(dummy); } else { - ERROR("Failed to send OPPORTUNISTIC message"); - message.state(Type::Message::FAILED); - - if (_failed_callback) { - _failed_callback(message); + if (_fallback_to_propagation) { + attempt_propagated("opportunistic send failed"); + } else { + fail_message("Failed to send OPPORTUNISTIC message"); } - - failed_outbound_push(message); - LXMessage dummy; - pending_outbound_pop(dummy); } } else { // DIRECT delivery - need a link for large messages DEBUG(" Using DIRECT delivery (via link)"); // Check if we have a path to the destination - if (!Transport::has_path(message.destination_hash())) { + if (!has_routable_path) { // Request path from network INFO(" No path to destination, requesting..."); Transport::request_path(message.destination_hash()); - _next_outbound_process_time = now + PATH_REQUEST_WAIT; + if (opportunistic_size_ok) { + INFO(" Small message is waiting for path-aware routing before send"); + } + if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) { + attempt_propagated(opportunistic_size_ok ? + "no path became available for path-aware opportunistic delivery" : + "no direct path became available"); + return; + } + retry_later(PATH_REQUEST_WAIT, OutboundStage::WAITING_PATH, " Waiting for direct path after path request"); return; } @@ -689,19 +1121,21 @@ void LXMRouter::process_outbound() { Link link = get_link_for_destination(message.destination_hash()); if (!link) { - WARNING("Failed to establish link for message delivery"); - // Set backoff timer to avoid tight loop - _next_outbound_process_time = now + OUTBOUND_RETRY_DELAY; - snprintf(buf, sizeof(buf), " Will retry in %d seconds", (int)OUTBOUND_RETRY_DELAY); - INFO(buf); + if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) { + attempt_propagated("direct link could not be established"); + return; + } + retry_later(OUTBOUND_RETRY_DELAY, OutboundStage::WAITING_DIRECT_LINK, " Direct link unavailable, will retry"); return; } // Check link status if (link.status() != RNS::Type::Link::ACTIVE) { - DEBUG("Link not yet active, waiting..."); - // Set shorter backoff for pending links - _next_outbound_process_time = now + 1.0; // Check again in 1 second + if (_fallback_to_propagation && current_attempts() >= PROPAGATION_FALLBACK_ATTEMPTS) { + attempt_propagated("direct link did not become active"); + return; + } + retry_later(1.0, OutboundStage::WAITING_DIRECT_LINK, " Direct link pending activation"); return; } @@ -709,41 +1143,27 @@ void LXMRouter::process_outbound() { if (send_via_link(message, link)) { INFO("Message sent successfully via link"); - // Call sent callback if registered - if (_sent_callback) { - _sent_callback(message); + if (context) { + context->stage = OutboundStage::DIRECT_TRANSFER; + context->next_attempt_time = now + (OUTBOUND_RETRY_DELAY * 2.0); } - - // Remove from pending queue - LXMessage dummy; - pending_outbound_pop(dummy); + return; } else { - ERROR("Failed to send message via link"); - message.state(Type::Message::FAILED); - - // Call failed callback - if (_failed_callback) { - _failed_callback(message); + if (_fallback_to_propagation) { + attempt_propagated("direct send failed"); + } else { + fail_message("Failed to send message via link"); } - - // Move to failed queue - failed_outbound_push(message); - LXMessage dummy; - pending_outbound_pop(dummy); } } } catch (const std::exception& e) { snprintf(buf, sizeof(buf), "Exception processing outbound message: %s", e.what()); ERROR(buf); - message.state(Type::Message::FAILED); - - // Call failed callback + log_state_transition(message, Type::Message::FAILED, "exception while processing outbound"); if (_failed_callback) { _failed_callback(message); } - - // Move to failed queue failed_outbound_push(message); LXMessage dummy; pending_outbound_pop(dummy); @@ -949,8 +1369,8 @@ void LXMRouter::on_packet(const Bytes& data, const Packet& packet) { snprintf(buf, sizeof(buf), " Unverified reason: %u", (uint8_t)message.unverified_reason()); DEBUG(buf); - // For Phase 1 MVP, we'll still accept messages with unknown source - // (signature will be validated later if source identity is learned) + // Accept messages with unknown source — signature will be validated + // later if the source identity is learned via announce if (message.unverified_reason() != Type::Message::SOURCE_UNKNOWN) { WARNING(" Rejecting message with invalid signature"); return; @@ -1104,7 +1524,7 @@ bool LXMRouter::send_via_link(LXMessage& message, Link& link) { return false; } - message.state(Type::Message::SENDING); + log_state_transition(message, Type::Message::SENDING, "sending via direct link"); if (message.representation() == Type::Message::PACKET) { // Send as single packet over link @@ -1112,10 +1532,20 @@ bool LXMRouter::send_via_link(LXMessage& message, Link& link) { INFO(buf); Packet packet(link, message.packed()); - packet.send(); + PacketReceipt receipt = packet.send(); + if (receipt) { + receipt.set_delivery_callback(static_proof_callback); + PendingProofSlot* slot = find_empty_pending_proof_slot(); + if (slot) { + slot->in_use = true; + slot->set_packet_hash(receipt.hash()); + slot->set_message_hash(message.hash()); + snprintf(buf, sizeof(buf), " Registered direct proof callback for packet %.16s...", receipt.hash().toHex().c_str()); + DEBUG(buf); + } + } - message.state(Type::Message::SENT); - INFO("Message sent successfully as packet"); + INFO("Message sent successfully as packet, waiting for proof"); return true; } else if (message.representation() == Type::Message::RESOURCE) { @@ -1141,8 +1571,7 @@ bool LXMRouter::send_via_link(LXMessage& message, Link& link) { } } - message.state(Type::Message::SENT); - INFO("Message resource transfer initiated"); + INFO("Message resource transfer initiated, waiting for completion"); return true; } else { @@ -1210,7 +1639,7 @@ bool LXMRouter::send_opportunistic(LXMessage& message, const Identity& dest_iden } } - message.state(Type::Message::SENT); + log_state_transition(message, Type::Message::SENT, "opportunistic packet transmitted"); INFO(" OPPORTUNISTIC packet sent"); return true; @@ -1244,8 +1673,12 @@ void LXMRouter::handle_direct_proof(const Bytes& message_hash) { break; } } - if (!already_notified && router && router->_delivered_callback) { + if (!already_notified && router) { notified_routers[notified_count++] = router; + router->update_pending_message_state(message_hash, Type::Message::DELIVERED, "direct delivery proof received"); + if (!router->_delivered_callback) { + continue; + } Bytes empty_hash; LXMessage msg(empty_hash, empty_hash); msg.hash(message_hash); @@ -1286,9 +1719,11 @@ void LXMRouter::on_incoming_link_established(Link& link) { snprintf(buf, sizeof(buf), " Link ID: %s", link.link_id().toHex().c_str()); DEBUG(buf); - // Set up resource concluded callback to receive LXMF messages over this link + // Set up packet callback for single-packet LXMF messages (CONTEXT_NONE) + link.set_packet_callback(static_link_packet_callback); + // Set up resource concluded callback for multi-packet LXMF messages link.set_resource_concluded_callback(static_resource_concluded_callback); - DEBUG(" Resource callback registered for incoming LXMF messages"); + DEBUG(" Packet and resource callbacks registered for incoming LXMF messages"); } // Resource concluded callback (LXMF message received via DIRECT delivery) @@ -1368,13 +1803,11 @@ void LXMRouter::on_resource_concluded(const RNS::Resource& resource) { // ============== Propagation Node Support ============== -void LXMRouter::set_propagation_node_manager(PropagationNodeManager* manager) { - _propagation_manager = manager; - INFO("Propagation node manager set"); -} - void LXMRouter::set_outbound_propagation_node(const Bytes& node_hash) { if (node_hash.size() == 0) { + if (_outbound_propagation_node.size() == 0) { + return; + } _outbound_propagation_node = {}; _outbound_propagation_link = Link(RNS::Type::NONE); INFO("Cleared outbound propagation node"); @@ -1382,6 +1815,9 @@ void LXMRouter::set_outbound_propagation_node(const Bytes& node_hash) { } // Check if changing to a different node + if (_outbound_propagation_node == node_hash) { + return; + } if (_outbound_propagation_node != node_hash) { // Tear down existing link if any if (_outbound_propagation_link && _outbound_propagation_link.status() != RNS::Type::Link::CLOSED) { @@ -1391,9 +1827,11 @@ void LXMRouter::set_outbound_propagation_node(const Bytes& node_hash) { } _outbound_propagation_node = node_hash; - char buf[64]; - snprintf(buf, sizeof(buf), "Set outbound propagation node to %.16s...", node_hash.toHex().c_str()); + char buf[96]; + snprintf(buf, sizeof(buf), "Set outbound propagation node (%d bytes): %s", + (int)node_hash.size(), node_hash.toHex().c_str()); INFO(buf); + nudge_propagation_sync("propagation node changed"); } void LXMRouter::register_sync_complete_callback(SyncCompleteCallback callback) { @@ -1421,53 +1859,63 @@ void LXMRouter::static_propagation_resource_concluded(const Resource& resource) if (resource.status() == RNS::Type::Resource::COMPLETE) { snprintf(buf, sizeof(buf), "PROPAGATED delivery to node confirmed for message %.16s...", message_hash.toHex().c_str()); INFO(buf); - - // For PROPAGATED, "delivered" means delivered to propagation node, not final recipient - // We mark it as SENT (not DELIVERED) to indicate it's on the propagation network - LXMRouter* notified_routers[ROUTER_REGISTRY_SIZE]; - size_t notified_count = 0; - - for (size_t i = 0; i < ROUTER_REGISTRY_SIZE; i++) { - if (_router_registry_pool[i].in_use) { - LXMRouter* router = _router_registry_pool[i].router; - bool already_notified = false; - for (size_t j = 0; j < notified_count; j++) { - if (notified_routers[j] == router) { - already_notified = true; - break; - } - } - if (!already_notified && router && router->_sent_callback) { - notified_routers[notified_count++] = router; - Bytes empty_hash; - LXMessage msg(empty_hash, empty_hash); - msg.hash(message_hash); - msg.state(Type::Message::SENT); - router->_sent_callback(msg); - } - } - } } else { snprintf(buf, sizeof(buf), "PROPAGATED resource transfer failed with status %d", (int)resource.status()); WARNING(buf); } slot->clear(); + + LXMRouter* notified_routers[ROUTER_REGISTRY_SIZE]; + size_t notified_count = 0; + + for (size_t i = 0; i < ROUTER_REGISTRY_SIZE; i++) { + if (!_router_registry_pool[i].in_use) { + continue; + } + + LXMRouter* router = _router_registry_pool[i].router; + bool already_notified = false; + for (size_t j = 0; j < notified_count; j++) { + if (notified_routers[j] == router) { + already_notified = true; + break; + } + } + if (already_notified || !router) { + continue; + } + notified_routers[notified_count++] = router; + + if (resource.status() == RNS::Type::Resource::COMPLETE) { + router->update_pending_message_state(message_hash, Type::Message::SENT, "propagation node accepted message"); + router->notify_sent_for_message_hash(message_hash, Type::Message::PROPAGATED); + router->nudge_propagation_sync("propagated transfer completed"); + } else { + LXMessage* pending = router->pending_outbound_find(message_hash); + if (pending) { + router->schedule_outbound_retry( + *pending, + OUTBOUND_RETRY_DELAY, + OutboundStage::WAITING_PROP_LINK, + "Propagation resource failed; scheduling retry" + ); + } + } + } } bool LXMRouter::send_propagated(LXMessage& message) { INFO("Sending LXMF message via PROPAGATED delivery"); char buf[128]; + OutboundContextSlot* context = get_or_create_outbound_context(message.hash()); + if (!context) { + ERROR("No outbound context slot available for propagated delivery"); + return false; + } // Get propagation node Bytes prop_node = _outbound_propagation_node; - if (prop_node.size() == 0 && _propagation_manager) { - DEBUG(" Looking for propagation node via manager..."); - auto nodes = _propagation_manager->get_nodes(); - snprintf(buf, sizeof(buf), " Manager has %zu nodes", nodes.size()); - DEBUG(buf); - prop_node = _propagation_manager->get_effective_node(); - } if (prop_node.size() == 0) { WARNING("No propagation node available for PROPAGATED delivery"); @@ -1485,6 +1933,8 @@ bool LXMRouter::send_propagated(LXMessage& message) { if (!Transport::has_path(prop_node)) { INFO(" No path to propagation node, requesting..."); Transport::request_path(prop_node); + schedule_outbound_retry(message, PATH_REQUEST_WAIT, OutboundStage::WAITING_PATH, + " Waiting for propagation-node path after request"); return false; // Will retry next cycle } @@ -1492,6 +1942,8 @@ bool LXMRouter::send_propagated(LXMessage& message) { Identity node_identity = Identity::recall(prop_node); if (!node_identity) { INFO(" Propagation node identity not known, waiting for announce..."); + schedule_outbound_retry(message, PATH_REQUEST_WAIT, OutboundStage::WAITING_PROP_LINK, + " Waiting for propagation-node identity"); return false; } @@ -1507,28 +1959,81 @@ bool LXMRouter::send_propagated(LXMessage& message) { // Create link with established callback _outbound_propagation_link = Link(prop_dest); INFO(" Establishing link to propagation node..."); + schedule_outbound_retry(message, 1.0, OutboundStage::WAITING_PROP_LINK, + " Waiting for propagation link activation"); return false; // Will retry when link established } // Check if link is active if (_outbound_propagation_link.status() != RNS::Type::Link::ACTIVE) { DEBUG(" Propagation link not yet active, waiting..."); + schedule_outbound_retry(message, 1.0, OutboundStage::WAITING_PROP_LINK, + " Propagation link pending activation"); return false; // Will retry } - // Generate propagation stamp if required by node - if (_propagation_manager) { - auto node_info = _propagation_manager->get_node(prop_node); - if (node_info && node_info.stamp_cost > 0) { - snprintf(buf, sizeof(buf), " Generating propagation stamp (cost=%u)...", node_info.stamp_cost); + // Generate propagation stamp asynchronously if required by node. + if (_outbound_propagation_stamp_cost > 0 && message.propagation_stamp().size() == 0) { +#ifdef ARDUINO + if (stamp_job_matches_hash(message.hash())) { + if (_propagation_stamp_job.completed) { + context->propagation_stamp_pending = false; + context->propagation_stamp_failed = false; + context->stage = OutboundStage::NONE; + _propagation_stamp_job.completed = false; + _propagation_stamp_job.message = nullptr; + _propagation_stamp_job.router = nullptr; + memset(_propagation_stamp_job.message_hash, 0, sizeof(_propagation_stamp_job.message_hash)); + INFO(" Propagation stamp job completed"); + } else if (_propagation_stamp_job.failed) { + context->propagation_stamp_pending = false; + context->propagation_stamp_failed = true; + _propagation_stamp_job.failed = false; + _propagation_stamp_job.message = nullptr; + _propagation_stamp_job.router = nullptr; + memset(_propagation_stamp_job.message_hash, 0, sizeof(_propagation_stamp_job.message_hash)); + WARNING(" Propagation stamp generation failed"); + return false; + } else { + context->propagation_stamp_pending = true; + context->stage = OutboundStage::WAITING_PROP_STAMP; + DEBUG(" Propagation stamp still generating in background"); + return false; + } + } +#endif + + if (!context->propagation_stamp_pending) { + snprintf(buf, sizeof(buf), " Queueing propagation stamp generation (cost=%u)...", _outbound_propagation_stamp_cost); DEBUG(buf); - Bytes stamp = message.generate_propagation_stamp(node_info.stamp_cost); - if (stamp.size() == 0) { - WARNING(" Failed to generate propagation stamp, sending anyway"); + if (!queue_propagation_stamp_job(message)) { + WARNING(" Could not queue propagation stamp job yet"); + schedule_outbound_retry(message, 1.0, OutboundStage::WAITING_PROP_STAMP, + " Waiting for propagation stamp worker availability"); + return false; } } + + context->propagation_stamp_pending = true; + context->stage = OutboundStage::WAITING_PROP_STAMP; + return false; } + if (context->propagation_stamp_failed) { + WARNING(" Propagation stamp generation previously failed"); + context->propagation_stamp_failed = false; + message.set_propagation_stamp({}); + return false; + } + + if (message.state() == Type::Message::SENDING && context->stage == OutboundStage::PROP_TRANSFER) { + DEBUG(" Propagation resource already in progress"); + return false; + } + + context->propagation_stamp_pending = false; + context->propagation_stamp_failed = false; + // Pack message for propagation Bytes prop_packed = message.pack_propagated(); if (!prop_packed || prop_packed.size() == 0) { @@ -1556,11 +2061,53 @@ bool LXMRouter::send_propagated(LXMessage& message) { } } - message.state(Type::Message::SENDING); + log_state_transition(message, Type::Message::SENDING, "propagated resource transfer initiated"); + context->stage = OutboundStage::PROP_TRANSFER; + context->next_attempt_time = Utilities::OS::time() + (OUTBOUND_RETRY_DELAY * 2.0); INFO(" PROPAGATED resource transfer initiated"); return true; } +// Static router pointer for sync callbacks (raw function pointers required by RequestReceipt) +static LXMRouter* _active_sync_router = nullptr; + +// Static callback wrappers for sync protocol +static void static_list_response_cb(const RequestReceipt& receipt) { + if (_active_sync_router) { + _active_sync_router->on_message_list_response(receipt.get_response()); + } else { + WARNING("list_response_cb: no active sync router!"); + } +} + +static void static_list_failed_cb(const RequestReceipt& receipt) { + WARNING("Propagation node list request failed"); + if (_active_sync_router) { + _active_sync_router->on_sync_failed(); + } +} + +static void static_get_response_cb(const RequestReceipt& receipt) { + if (_active_sync_router) { + _active_sync_router->on_message_get_response(receipt.get_response()); + } else { + WARNING("get_response_cb: no active sync router!"); + } +} + +static void static_get_failed_cb(const RequestReceipt& receipt) { + WARNING("Propagation node get request failed"); + if (_active_sync_router) { + _active_sync_router->on_sync_failed(); + } +} + +void LXMRouter::on_sync_failed() { + _sync_state = PR_FAILED; + _sync_progress = 0.0f; + _active_sync_router = nullptr; +} + void LXMRouter::request_messages_from_propagation_node() { if (_sync_state != PR_IDLE && _sync_state != PR_COMPLETE && _sync_state != PR_FAILED) { char buf[64]; @@ -1571,9 +2118,6 @@ void LXMRouter::request_messages_from_propagation_node() { // Get propagation node Bytes prop_node = _outbound_propagation_node; - if (!prop_node && _propagation_manager) { - prop_node = _propagation_manager->get_effective_node(); - } if (!prop_node) { WARNING("No propagation node available for sync"); @@ -1586,57 +2130,309 @@ void LXMRouter::request_messages_from_propagation_node() { INFO(buf); _sync_progress = 0.0f; - // Check if link exists and is active + // Request path if we don't have one + if (!Transport::has_path(prop_node)) { + INFO(" No path to propagation node, requesting..."); + Transport::request_path(prop_node); + } + + _sync_state = PR_PATH_REQUESTED; // Enter state machine; process_sync() advances it + _sync_start_time = Utilities::OS::time(); + process_sync(); +} + +void LXMRouter::process_sync() { + if (_sync_state == PR_IDLE || _sync_state == PR_COMPLETE || _sync_state == PR_FAILED) { + return; // Nothing to advance + } + + // Global timeout: 60s for entire sync operation + double elapsed = Utilities::OS::time() - _sync_start_time; + if (elapsed > 60.0) { + WARNING(" Propagation sync timed out"); + on_sync_failed(); + return; + } + + Bytes prop_node = _outbound_propagation_node; + if (!prop_node) { + _sync_state = PR_FAILED; + return; + } + + // For states that depend on an active link, check link health + if (_sync_state == PR_REQUEST_SENT || _sync_state == PR_RECEIVING || + _sync_state == PR_LINK_ESTABLISHED) { + if (!_outbound_propagation_link || + _outbound_propagation_link.status() == RNS::Type::Link::CLOSED) { + WARNING(" Propagation link lost during sync"); + on_sync_failed(); + return; + } + // Log status every ~10s + int elapsed_int = (int)elapsed; + if (elapsed_int > 0 && elapsed_int % 10 == 0) { + static int last_logged = -1; + if (elapsed_int != last_logged) { + last_logged = elapsed_int; + char buf[96]; + snprintf(buf, sizeof(buf), " Sync waiting: state=%d, link_status=%d, pending_reqs=%zu, elapsed=%ds", + (int)_sync_state, (int)_outbound_propagation_link.status(), + _outbound_propagation_link.pending_requests_count(), elapsed_int); + INFO(buf); + } + } + return; // Waiting for response callbacks + } + + // Check if link is already active (fast path for PR_PATH_REQUESTED / PR_LINK_ESTABLISHING) if (_outbound_propagation_link && _outbound_propagation_link.status() == RNS::Type::Link::ACTIVE) { _sync_state = PR_LINK_ESTABLISHED; - // TODO: Implement link.identify() and link.request() for full sync protocol - // For now, we log that sync would happen here - INFO(" Link active - sync protocol not yet implemented"); - INFO(" (Requires Link.identify() and Link.request() support)"); + _active_sync_router = this; + // Identify ourselves to the propagation node + _outbound_propagation_link.identify(_identity); + + // Build initial request: [nil, nil] — request message list + MsgPack::Packer packer; + packer.packArraySize(2); + packer.packNil(); + packer.packNil(); + Bytes request_data(packer.data(), packer.size()); + + // Request message list via "/get" path + Bytes path((uint8_t*)"/get", 4); + RequestReceipt receipt = _outbound_propagation_link.request( + path, request_data, + static_list_response_cb, + static_list_failed_cb + ); + + if (!receipt) { + WARNING(" Sync request failed - request not sent"); + on_sync_failed(); + return; + } + + _sync_state = PR_REQUEST_SENT; + _sync_progress = 0.1f; + INFO(" Sync request sent to propagation node"); + return; + } + + // PR_PATH_REQUESTED: wait for path, then advance to link establishing + if (_sync_state == PR_PATH_REQUESTED) { + if (!Transport::has_path(prop_node)) { + return; // Still waiting for path + } + + Identity node_identity = Identity::recall(prop_node); + if (!node_identity) { + INFO(" Propagation node identity not known"); + _sync_state = PR_FAILED; + return; + } + + Destination prop_dest( + node_identity, + RNS::Type::Destination::OUT, + RNS::Type::Destination::SINGLE, + "lxmf", + "propagation" + ); + + _outbound_propagation_link = Link(prop_dest); + _sync_state = PR_LINK_ESTABLISHING; + INFO(" Path arrived, establishing link for sync..."); + return; + } + + // PR_LINK_ESTABLISHING: link not yet active, check for failure + if (_sync_state == PR_LINK_ESTABLISHING) { + if (_outbound_propagation_link.status() == RNS::Type::Link::CLOSED) { + WARNING(" Propagation link closed before establishing"); + _sync_state = PR_FAILED; + } + // Otherwise still waiting for link to become ACTIVE + } +} + +void LXMRouter::on_message_list_response(const Bytes& response) { + char buf[128]; + INFO("Received message list from propagation node"); + + if (!response || response.size() == 0) { + INFO(" Empty response — no messages available"); _sync_state = PR_COMPLETE; _sync_progress = 1.0f; + _active_sync_router = nullptr; if (_sync_complete_callback) { _sync_complete_callback(0); } - } else { - // Need to establish link first - if (!Transport::has_path(prop_node)) { - INFO(" No path to propagation node, requesting..."); - Transport::request_path(prop_node); - _sync_state = PR_PATH_REQUESTED; - } else { - Identity node_identity = Identity::recall(prop_node); - if (!node_identity) { - INFO(" Propagation node identity not known"); - _sync_state = PR_FAILED; - return; + return; + } + + try { + // Parse response: array of transient_id bytes + MsgPack::Unpacker unpacker; + unpacker.feed(response.data(), response.size()); + + MsgPack::arr_size_t arr_size; + unpacker.deserialize(arr_size); + + snprintf(buf, sizeof(buf), " Propagation node has %u messages", (unsigned)arr_size.size()); + INFO(buf); + + // Filter out already-seen transient IDs + // Build "wants" list + size_t wants_count = 0; + + std::vector available_ids; + for (size_t i = 0; i < arr_size.size(); i++) { + MsgPack::bin_t id_bin; + unpacker.deserialize(id_bin); + Bytes transient_id(id_bin); + if (!transient_ids_contains(transient_id)) { + available_ids.push_back(transient_id); + wants_count++; } + } + + snprintf(buf, sizeof(buf), " Want %zu new messages (filtered %u already seen)", + wants_count, (unsigned)(arr_size.size() - wants_count)); + INFO(buf); - Destination prop_dest( - node_identity, - RNS::Type::Destination::OUT, - RNS::Type::Destination::SINGLE, - "lxmf", - "propagation" - ); + if (wants_count == 0) { + INFO(" No new messages to download"); + _sync_state = PR_COMPLETE; + _sync_progress = 1.0f; + _active_sync_router = nullptr; + if (_sync_complete_callback) { + _sync_complete_callback(0); + } + return; + } - _outbound_propagation_link = Link(prop_dest); - _sync_state = PR_LINK_ESTABLISHING; - INFO(" Establishing link for sync..."); + // Build request: [wants, [], 0] + // wants = array of transient IDs we want + // [] = empty "have" list (we're not a peer) + // 0 = message limit (0 = no limit) + MsgPack::Packer req_packer; + req_packer.packArraySize(3); + + // wants array + req_packer.packArraySize(wants_count); + for (const auto& id : available_ids) { + req_packer.packBinary(id.data(), id.size()); } - } -} -void LXMRouter::on_message_list_response(const Bytes& response) { - // TODO: Implement when Link.request() is available - DEBUG("on_message_list_response: Not yet implemented"); + // empty haves array + req_packer.packArraySize(0); + + // no limit (must be nil, not 0 — Python server treats 0 as "0 KB limit") + req_packer.packNil(); + + Bytes request_data(req_packer.data(), req_packer.size()); + + // Request the messages + Bytes path((uint8_t*)"/get", 4); + _outbound_propagation_link.request( + path, request_data, + static_get_response_cb, + static_get_failed_cb + ); + + _sync_state = PR_RECEIVING; + _sync_progress = 0.3f; + + } catch (const std::exception& e) { + snprintf(buf, sizeof(buf), "Failed to parse message list: %s", e.what()); + ERROR(buf); + _sync_state = PR_FAILED; + _active_sync_router = nullptr; + } } void LXMRouter::on_message_get_response(const Bytes& response) { - // TODO: Implement when Link.request() is available - DEBUG("on_message_get_response: Not yet implemented"); + char buf[128]; + INFO("Received messages from propagation node"); + + if (!response || response.size() == 0) { + INFO(" Empty response"); + _sync_state = PR_COMPLETE; + _sync_progress = 1.0f; + _active_sync_router = nullptr; + if (_sync_complete_callback) { + _sync_complete_callback(0); + } + return; + } + + size_t messages_received = 0; + + try { + // Parse response: array of lxmf_data bytes + MsgPack::Unpacker unpacker; + unpacker.feed(response.data(), response.size()); + + MsgPack::arr_size_t arr_size; + unpacker.deserialize(arr_size); + + snprintf(buf, sizeof(buf), " Processing %u messages from propagation node", (unsigned)arr_size.size()); + INFO(buf); + + // Collect received transient IDs for ack + std::vector received_ids; + + for (size_t i = 0; i < arr_size.size(); i++) { + MsgPack::bin_t data_bin; + unpacker.deserialize(data_bin); + Bytes lxmf_data(data_bin); + + // Process the message + process_propagated_lxmf(lxmf_data); + messages_received++; + + // Track transient ID + Bytes transient_id = Identity::full_hash(lxmf_data); + received_ids.push_back(transient_id); + + _sync_progress = 0.3f + 0.6f * ((float)(i + 1) / (float)arr_size.size()); + } + + // Send ack: [nil, haves] — acknowledge received messages + if (!received_ids.empty() && _outbound_propagation_link && + _outbound_propagation_link.status() == RNS::Type::Link::ACTIVE) { + MsgPack::Packer ack_packer; + ack_packer.packArraySize(2); + ack_packer.packNil(); + + ack_packer.packArraySize(received_ids.size()); + for (const auto& id : received_ids) { + ack_packer.packBinary(id.data(), id.size()); + } + + Bytes ack_data(ack_packer.data(), ack_packer.size()); + Bytes path((uint8_t*)"/get", 4); + _outbound_propagation_link.request(path, ack_data); + } + + } catch (const std::exception& e) { + snprintf(buf, sizeof(buf), "Failed to process messages from propagation node: %s", e.what()); + ERROR(buf); + } + + snprintf(buf, sizeof(buf), "Sync complete: received %zu messages", messages_received); + INFO(buf); + + _sync_state = PR_COMPLETE; + _sync_progress = 1.0f; + _active_sync_router = nullptr; + + if (_sync_complete_callback) { + _sync_complete_callback(messages_received); + } } void LXMRouter::process_propagated_lxmf(const Bytes& lxmf_data) { diff --git a/src/LXMF/LXMRouter.h b/src/LXMF/LXMRouter.h index f7134787..c4ae5c69 100644 --- a/src/LXMF/LXMRouter.h +++ b/src/LXMF/LXMRouter.h @@ -15,14 +15,11 @@ namespace LXMF { - // Forward declarations - class PropagationNodeManager; - /** * @brief LXMF Router - Message delivery orchestration * * Manages message queues, link establishment, and delivery for LXMF messages. - * Supports DIRECT delivery method (via established links) for Phase 1 MVP. + * Supports DIRECT, OPPORTUNISTIC, and PROPAGATED delivery methods. * * Usage: * LXMRouter router(identity, "/path/to/storage"); @@ -255,11 +252,19 @@ namespace LXMF { // ============== Propagation Node Support ============== /** - * @brief Set the propagation node manager + * @brief Set the stamp cost required by the outbound propagation node + * + * When set to a non-zero value, stamps will be generated before sending + * messages through the propagation node. * - * @param manager Pointer to PropagationNodeManager (not owned) + * @param cost Required stamp cost (0 = no stamp needed) + */ + void set_outbound_propagation_stamp_cost(uint8_t cost) { _outbound_propagation_stamp_cost = cost; } + + /** + * @brief Get the stamp cost for the outbound propagation node */ - void set_propagation_node_manager(PropagationNodeManager* manager); + uint8_t outbound_propagation_stamp_cost() const { return _outbound_propagation_stamp_cost; } /** * @brief Set the outbound propagation node @@ -317,6 +322,14 @@ namespace LXMF { */ void request_messages_from_propagation_node(); + /** + * @brief Advance propagation sync state machine + * + * Call periodically (e.g., in main loop) to advance sync after + * path arrival or link establishment. + */ + void process_sync(); + /** * @brief Get the current sync state * @@ -469,16 +482,26 @@ namespace LXMF { */ bool send_propagated(LXMessage& message); + public: /** * @brief Handle message list response from propagation node + * NOTE: Public for static callback access, not intended for direct use. */ void on_message_list_response(const RNS::Bytes& response); /** * @brief Handle message get response from propagation node + * NOTE: Public for static callback access, not intended for direct use. */ void on_message_get_response(const RNS::Bytes& response); + /** + * @brief Handle sync failure + * NOTE: Public for static callback access, not intended for direct use. + */ + void on_sync_failed(); + + private: /** * @brief Process received propagated LXMF data */ @@ -489,16 +512,77 @@ namespace LXMF { */ static void static_propagation_resource_concluded(const RNS::Resource& resource); + enum class OutboundStage : uint8_t { + NONE = 0, + WAITING_PATH, + WAITING_DIRECT_LINK, + DIRECT_TRANSFER, + WAITING_PROP_LINK, + WAITING_PROP_STAMP, + PROP_TRANSFER + }; + // Circular buffer helpers for message queues bool pending_outbound_push(const LXMessage& msg); bool pending_outbound_pop(LXMessage& msg); LXMessage* pending_outbound_front(); + LXMessage* pending_outbound_find(const RNS::Bytes& hash); bool pending_inbound_push(const LXMessage& msg); bool pending_inbound_pop(LXMessage& msg); LXMessage* pending_inbound_front(); bool failed_outbound_push(const LXMessage& msg); bool failed_outbound_pop(LXMessage& msg); + struct OutboundContextSlot { + bool in_use = false; + static constexpr size_t MESSAGE_HASH_SIZE = 32; + uint8_t message_hash[MESSAGE_HASH_SIZE]; + double next_attempt_time = 0.0; + OutboundStage stage = OutboundStage::NONE; + bool propagation_stamp_pending = false; + bool propagation_stamp_failed = false; + bool avoid_auto_interface_path = false; + bool using_propagated_retry_budget = false; + uint8_t propagation_attempts = 0; + void set_message_hash(const RNS::Bytes& b) { + size_t len = std::min(b.size(), MESSAGE_HASH_SIZE); + memcpy(message_hash, b.data(), len); + if (len < MESSAGE_HASH_SIZE) memset(message_hash + len, 0, MESSAGE_HASH_SIZE - len); + } + bool message_hash_equals(const RNS::Bytes& b) const { + if (b.size() != MESSAGE_HASH_SIZE) return false; + return memcmp(message_hash, b.data(), MESSAGE_HASH_SIZE) == 0; + } + void clear() { + in_use = false; + memset(message_hash, 0, MESSAGE_HASH_SIZE); + next_attempt_time = 0.0; + stage = OutboundStage::NONE; + propagation_stamp_pending = false; + propagation_stamp_failed = false; + avoid_auto_interface_path = false; + using_propagated_retry_budget = false; + propagation_attempts = 0; + } + }; + + OutboundContextSlot* find_outbound_context_slot(const RNS::Bytes& hash); + OutboundContextSlot* find_empty_outbound_context_slot(); + OutboundContextSlot* get_or_create_outbound_context(const RNS::Bytes& hash); + void clear_outbound_context(const RNS::Bytes& hash); + void schedule_outbound_retry(LXMessage& message, double delay, OutboundStage stage, const std::string& reason); + void update_pending_message_state(const RNS::Bytes& message_hash, Type::Message::State state, const std::string& reason); + void notify_sent_for_message_hash( + const RNS::Bytes& message_hash, + Type::Message::Method method = Type::Message::DIRECT + ); + void notify_delivered_for_message_hash(const RNS::Bytes& message_hash); + void notify_failed_for_message_hash(const RNS::Bytes& message_hash); + bool path_supports_opportunistic(const RNS::Bytes& destination_hash, bool avoid_auto_interface_path = false) const; + bool ensure_stamp_worker_started(); + bool queue_propagation_stamp_job(LXMessage& message); + void nudge_propagation_sync(const std::string& reason); + private: // Core components RNS::Identity _identity; // Local identity @@ -524,6 +608,9 @@ namespace LXMF { size_t _failed_outbound_tail = 0; size_t _failed_outbound_count = 0; + static constexpr size_t OUTBOUND_CONTEXTS_SIZE = PENDING_OUTBOUND_SIZE; + OutboundContextSlot _outbound_contexts_pool[OUTBOUND_CONTEXTS_SIZE]; + // Link management for DIRECT delivery - fixed pool (zero heap fragmentation) static constexpr size_t DIRECT_LINKS_SIZE = 8; static constexpr size_t DEST_HASH_SIZE = 16; // Truncated hash size @@ -614,12 +701,17 @@ namespace LXMF { // Retry backoff double _next_outbound_process_time = 0.0; // Next time to process outbound queue - static constexpr double OUTBOUND_RETRY_DELAY = 5.0; // Seconds between retries - static constexpr double PATH_REQUEST_WAIT = 3.0; // Seconds to wait after path request + static constexpr double OUTBOUND_RETRY_DELAY = 10.0; // Seconds between retries (Python: DELIVERY_RETRY_WAIT = 10) + static constexpr double PATH_REQUEST_WAIT = 15.0; // Seconds to wait after path request (Python: 7s, but LoRa needs more RX window) + static constexpr int MAX_DELIVERY_ATTEMPTS = 5; // Max attempts before failing (Python: 5) + static constexpr int MAX_PROPAGATION_DELIVERY_ATTEMPTS = 8; + static constexpr int MAX_PATHLESS_TRIES = 1; // Attempts before requesting path (Python: 1) + static constexpr int PROPAGATION_FALLBACK_ATTEMPTS = 3; // Direct/opportunistic retries before fallback/fail + static constexpr double PROPAGATION_SYNC_NUDGE_INTERVAL = 15.0; // Propagation node support - PropagationNodeManager* _propagation_manager = nullptr; RNS::Bytes _outbound_propagation_node; + uint8_t _outbound_propagation_stamp_cost = 0; RNS::Link _outbound_propagation_link{RNS::Type::NONE}; bool _fallback_to_propagation = true; bool _propagation_only = false; @@ -627,6 +719,8 @@ namespace LXMF { // Propagation sync state PropagationSyncState _sync_state = PR_IDLE; float _sync_progress = 0.0f; + double _sync_start_time = 0.0; + double _last_propagation_sync_nudge = 0.0; SyncCompleteCallback _sync_complete_callback; // Locally delivered transient IDs circular buffer (zero heap fragmentation) diff --git a/src/LXMF/LXMessage.cpp b/src/LXMF/LXMessage.cpp index 176cdc29..e0d2e7be 100644 --- a/src/LXMF/LXMessage.cpp +++ b/src/LXMF/LXMessage.cpp @@ -125,14 +125,12 @@ const Bytes& LXMessage::pack() { _timestamp = Utilities::OS::time(); } - // 2. Create payload array: [timestamp, title, content, fields, stamp?] - matches Python LXMF exactly - // Python: msgpack.packb([self.timestamp, self.title, self.content, self.fields]) - // If stamp is present, it's appended as 5th element + // 2. Pack 4-element payload (without stamp) for hash/signature computation. + // Per Python LXMF: hash and signature are ALWAYS computed over the 4-element + // payload [timestamp, title, content, fields], even when a stamp is present. + // The stamp is only appended as a 5th element in the wire format. MsgPack::Packer packer; - - // Pack as array with 4 or 5 elements (5 if stamp present) - bool has_stamp = (_stamp.size() == LXStamper::STAMP_SIZE); - packer.packArraySize(has_stamp ? 5 : 4); + packer.packArraySize(4); // Element 0: timestamp (float64) packer.pack(_timestamp); @@ -144,27 +142,23 @@ const Bytes& LXMessage::pack() { packer.packBinary(_content.data(), _content.size()); // Element 3: fields (map) - iterate over fixed array + // Keys are packed as integers to match Python LXMF (not BIN) packer.packMapSize(_fields_count); for (size_t i = 0; i < MAX_FIELDS; ++i) { if (_fields_pool[i].in_use) { - packer.packBinary(_fields_pool[i].key.data(), _fields_pool[i].key.size()); + packer.pack(_fields_pool[i].key.data()[0]); packer.packBinary(_fields_pool[i].value.data(), _fields_pool[i].value.size()); } } - // Element 4 (optional): stamp - 32 bytes - if (has_stamp) { - packer.packBinary(_stamp.data(), _stamp.size()); - DEBUG(" Stamp included in payload (" + std::to_string(_stamp.size()) + " bytes)"); - } - - Bytes packed_payload(packer.data(), packer.size()); + Bytes payload_without_stamp(packer.data(), packer.size()); - // 3. Calculate hash: SHA256(dest_hash + source_hash + packed_payload) + // 3. Calculate hash: SHA256(dest_hash + source_hash + payload_without_stamp) + // Hash is always over the 4-element payload (matching Python LXMF) Bytes hashed_part; hashed_part << _destination_hash; hashed_part << _source_hash; - hashed_part << packed_payload; + hashed_part << payload_without_stamp; _hash = Identity::full_hash(hashed_part); @@ -185,21 +179,44 @@ const Bytes& LXMessage::pack() { throw std::runtime_error("Cannot sign message without source destination"); } - // 6. Pack final message: dest_hash + source_hash + signature + packed_payload + // 6. Build wire payload — append stamp as 5th element if present + bool has_stamp = (_stamp.size() == LXStamper::STAMP_SIZE); + Bytes wire_payload; + if (has_stamp) { + MsgPack::Packer wire_packer; + wire_packer.packArraySize(5); + wire_packer.pack(_timestamp); + wire_packer.packBinary(_title.data(), _title.size()); + wire_packer.packBinary(_content.data(), _content.size()); + wire_packer.packMapSize(_fields_count); + for (size_t i = 0; i < MAX_FIELDS; ++i) { + if (_fields_pool[i].in_use) { + wire_packer.pack(_fields_pool[i].key.data()[0]); + wire_packer.packBinary(_fields_pool[i].value.data(), _fields_pool[i].value.size()); + } + } + wire_packer.packBinary(_stamp.data(), _stamp.size()); + wire_payload = Bytes(wire_packer.data(), wire_packer.size()); + DEBUG(" Stamp included in wire payload (" + std::to_string(_stamp.size()) + " bytes)"); + } else { + wire_payload = payload_without_stamp; + } + + // 7. Pack final message: dest_hash + source_hash + signature + wire_payload _packed.clear(); _packed << _destination_hash; _packed << _source_hash; _packed << _signature; - _packed << packed_payload; + _packed << wire_payload; _packed_valid = true; - // 7. Determine delivery method and representation - size_t content_size = packed_payload.size() - Type::Constants::TIMESTAMP_SIZE - Type::Constants::STRUCT_OVERHEAD; + // 8. Determine delivery method and representation + size_t content_size = wire_payload.size() - Type::Constants::TIMESTAMP_SIZE - Type::Constants::STRUCT_OVERHEAD; - // For Phase 1 MVP, we only support DIRECT delivery if (_desired_method == Type::Message::DIRECT) { - if (content_size <= Type::Constants::LINK_PACKET_MAX_CONTENT) { + // Use LoRa-constrained limit (63 bytes content) to ensure link packets fit within LoRa wire MTU + if (content_size <= Type::Constants::LORA_LINK_PACKET_MAX_CONTENT) { _method = Type::Message::DIRECT; _representation = Type::Message::PACKET; INFO(" Message will be sent as single packet (" + std::to_string(_packed.size()) + " bytes)"); @@ -208,8 +225,27 @@ const Bytes& LXMessage::pack() { _representation = Type::Message::RESOURCE; INFO(" Message will be sent as resource (" + std::to_string(_packed.size()) + " bytes)"); } + } else if (_desired_method == Type::Message::PROPAGATED) { + // PROPAGATED: always use resource transfer to propagation node + _method = Type::Message::PROPAGATED; + _representation = Type::Message::RESOURCE; + INFO(" Message will be sent via propagation (" + std::to_string(_packed.size()) + " bytes)"); + } else if (_desired_method == Type::Message::OPPORTUNISTIC) { + // OPPORTUNISTIC: single encrypted packet, no link required + // Use general ENCRYPTED_PACKET_MDU (not LoRa-specific) since caller + // explicitly requested OPPORTUNISTIC — matches Python LXMF behavior + if (_packed.size() <= Type::Constants::ENCRYPTED_PACKET_MDU) { + _method = Type::Message::OPPORTUNISTIC; + _representation = Type::Message::PACKET; + INFO(" Message will be sent opportunistically (" + std::to_string(_packed.size()) + " bytes)"); + } else { + // Too large for single packet, fall back to DIRECT + _method = Type::Message::DIRECT; + _representation = Type::Message::RESOURCE; + INFO(" Message too large for OPPORTUNISTIC, using DIRECT resource (" + std::to_string(_packed.size()) + " bytes)"); + } } else { - WARNING("Only DIRECT delivery method is supported in Phase 1 MVP"); + // Default fallback _method = Type::Message::DIRECT; _representation = Type::Message::PACKET; } @@ -218,7 +254,7 @@ const Bytes& LXMessage::pack() { INFO("Message packed successfully (" + std::to_string(_packed.size()) + " bytes total)"); DEBUG(" Overhead: " + std::to_string(Type::Constants::LXMF_OVERHEAD) + " bytes"); - DEBUG(" Payload: " + std::to_string(packed_payload.size()) + " bytes"); + DEBUG(" Payload: " + std::to_string(wire_payload.size()) + " bytes"); return _packed; } @@ -310,15 +346,16 @@ LXMessage LXMessage::unpack_from_bytes(const Bytes& lxmf_bytes, Type::Message::M DEBUG(" Msgpack map size: " + std::to_string(map_size.size())); // Unpack each field (key-value pairs) into temporary storage + // Python LXMF packs field keys as integers, values as BIN for (size_t i = 0; i < map_size.size() && temp_fields_count < MAX_FIELDS; ++i) { - MsgPack::bin_t key_bin; + uint8_t key_int; MsgPack::bin_t value_bin; - unpacker.deserialize(key_bin); + unpacker.deserialize(key_int); unpacker.deserialize(value_bin); temp_fields[temp_fields_count].in_use = true; - temp_fields[temp_fields_count].key = Bytes(key_bin); + temp_fields[temp_fields_count].key = Bytes(&key_int, 1); temp_fields[temp_fields_count].value = Bytes(value_bin); ++temp_fields_count; } @@ -367,10 +404,29 @@ LXMessage LXMessage::unpack_from_bytes(const Bytes& lxmf_bytes, Type::Message::M } // 4. Calculate hash for verification + // Per Python LXMF: hash is computed over 4-element payload (without stamp). + // If stamp was present, re-pack without it. + Bytes payload_for_hash; + if (stamp.size() == LXStamper::STAMP_SIZE) { + MsgPack::Packer repacker; + repacker.packArraySize(4); + repacker.pack(timestamp); + repacker.packBinary(title.data(), title.size()); + repacker.packBinary(content.data(), content.size()); + repacker.packMapSize(temp_fields_count); + for (size_t i = 0; i < temp_fields_count; ++i) { + repacker.pack(temp_fields[i].key.data()[0]); + repacker.packBinary(temp_fields[i].value.data(), temp_fields[i].value.size()); + } + payload_for_hash = Bytes(repacker.data(), repacker.size()); + } else { + payload_for_hash = packed_payload; + } + Bytes hashed_part; hashed_part << destination_hash; hashed_part << source_hash; - hashed_part << packed_payload; + hashed_part << payload_for_hash; message._hash = Identity::full_hash(hashed_part); @@ -438,21 +494,22 @@ bool LXMessage::validate_signature() { } } - // Reconstruct signed part + // Reconstruct signed part — must match pack() exactly Bytes hashed_part; hashed_part << _destination_hash; hashed_part << _source_hash; - // Need to repack payload for hashed_part + // Repack 4-element payload for hash/sig (without stamp, matching Python LXMF) MsgPack::Packer packer; - packer.serialize(_timestamp); - packer.serialize(_title); - packer.serialize(_content); - packer.serialize((uint32_t)_fields_count); + packer.packArraySize(4); + packer.pack(_timestamp); + packer.packBinary(_title.data(), _title.size()); + packer.packBinary(_content.data(), _content.size()); + packer.packMapSize(_fields_count); for (size_t i = 0; i < MAX_FIELDS; ++i) { if (_fields_pool[i].in_use) { - packer.serialize(_fields_pool[i].key); - packer.serialize(_fields_pool[i].value); + packer.pack(_fields_pool[i].key.data()[0]); + packer.packBinary(_fields_pool[i].value.data(), _fields_pool[i].value.size()); } } Bytes packed_payload(packer.data(), packer.size()); diff --git a/src/LXMF/LXMessage.h b/src/LXMF/LXMessage.h index 2b81ee20..d3942b67 100644 --- a/src/LXMF/LXMessage.h +++ b/src/LXMF/LXMessage.h @@ -219,6 +219,9 @@ namespace LXMF { */ inline void state(Type::Message::State state) { _state = state; } + inline int delivery_attempts() const { return _delivery_attempts; } + inline void increment_delivery_attempts() { _delivery_attempts++; } + /** * @brief Get message hash (ID) */ @@ -386,6 +389,7 @@ namespace LXMF { // Message state Type::Message::State _state = Type::Message::GENERATING; + int _delivery_attempts = 0; // Signature validation bool _signature_validated = false; diff --git a/src/LXMF/MessageStore.cpp b/src/LXMF/MessageStore.cpp index 5f66ce38..f980fbad 100644 --- a/src/LXMF/MessageStore.cpp +++ b/src/LXMF/MessageStore.cpp @@ -4,11 +4,42 @@ #include #include -#include +#include +#include +#include + +#ifdef ARDUINO +#include +#endif using namespace LXMF; using namespace RNS; +namespace { + +static std::string make_message_preview(const Bytes& content) { + size_t preview_len = std::min(content.size(), 60); + std::string preview; + preview.assign(reinterpret_cast(content.data()), preview_len); + if (content.size() > preview_len) { + preview += "..."; + } + return preview; +} + +static bool write_json_document(const std::string& path, JsonDocument& doc) { + std::string json_str; + serializeJson(doc, json_str); + Bytes data(reinterpret_cast(json_str.data()), json_str.size()); + return Utilities::OS::write_file(path.c_str(), data) == data.size(); +} + +static bool write_bytes_file(const std::string& path, const Bytes& data) { + return Utilities::OS::write_file(path.c_str(), data) == data.size(); +} + +} // namespace + // ConversationInfo helper methods bool MessageStore::ConversationInfo::add_message_hash(const Bytes& hash) { // Check if already exists @@ -62,6 +93,7 @@ void MessageStore::ConversationInfo::clear() { last_activity = 0.0; unread_count = 0; memset(last_message_hash, 0, MESSAGE_HASH_SIZE); + memset(last_message_preview, 0, sizeof(last_message_preview)); } // ConversationSlot helper method @@ -78,14 +110,38 @@ MessageStore::MessageStore(const std::string& base_path) : { INFO("Initializing MessageStore at: " + _base_path); - // Initialize pool + // Allocate conversation pool off internal heap when PSRAM is available. + const size_t pool_bytes = sizeof(ConversationSlot) * MAX_CONVERSATIONS; + void* pool_mem = nullptr; + bool pool_from_psram = false; +#ifdef ARDUINO + pool_mem = heap_caps_malloc(pool_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!pool_mem) { + WARNING("MessageStore: PSRAM pool allocation failed, falling back to internal heap"); + pool_mem = std::malloc(pool_bytes); + } else { + pool_from_psram = true; + } +#else + pool_mem = std::malloc(pool_bytes); +#endif + + if (!pool_mem) { + ERROR("Failed to allocate conversation pool"); + return; + } + + _conversations_pool = std::unique_ptr( + static_cast(pool_mem), + ConversationPoolDeleter(pool_from_psram) + ); for (size_t i = 0; i < MAX_CONVERSATIONS; ++i) { _conversations_pool[i].clear(); } if (initialize_storage()) { - load_index(); _initialized = true; + load_index(); INFO("MessageStore initialized with " + std::to_string(count_conversations()) + " conversations"); } else { ERROR("Failed to initialize MessageStore"); @@ -96,6 +152,7 @@ MessageStore::~MessageStore() { if (_initialized) { save_index(); } + _conversations_pool.reset(); TRACE("MessageStore destroyed"); } @@ -113,6 +170,8 @@ bool MessageStore::initialize_storage() { // Load conversation index from disk void MessageStore::load_index() { std::string index_path = "/conv.json"; // Short path for SPIFFS + bool index_needs_save = false; + std::vector> preview_backfill; if (!Utilities::OS::file_exists(index_path.c_str())) { DEBUG("No existing conversation index found"); @@ -178,9 +237,42 @@ void MessageStore::load_index() { slot.info.set_last_message_hash(last_msg_bytes); } + if (conv["last_message_preview"].is()) { + slot.info.set_last_message_preview(conv["last_message_preview"].as()); + } else { + Bytes last_msg_hash = slot.info.last_message_hash_bytes(); + if (last_msg_hash) { + preview_backfill.push_back(std::make_pair(slot_index, last_msg_hash)); + } + } + ++slot_index; } + for (const auto& entry : preview_backfill) { + if (entry.first >= MAX_CONVERSATIONS) { + continue; + } + + ConversationSlot& slot = _conversations_pool[entry.first]; + if (!slot.in_use) { + continue; + } + + MessageMetadata meta = load_message_metadata(entry.second); + if (meta.valid && !meta.content.empty()) { + slot.info.set_last_message_preview(make_message_preview(Bytes( + reinterpret_cast(meta.content.data()), + meta.content.size() + ))); + index_needs_save = true; + } + } + + if (index_needs_save) { + save_index(); + } + DEBUG("Loaded " + std::to_string(count_conversations()) + " conversations from index"); } catch (const std::exception& e) { @@ -216,6 +308,10 @@ bool MessageStore::save_index() { conv["last_message_hash"] = last_msg.toHex(); } + if (info.last_message_preview_cstr()[0] != '\0') { + conv["last_message_preview"] = info.last_message_preview_cstr(); + } + // Serialize message hashes JsonArray messages = conv["messages"].to(); for (size_t j = 0; j < info.message_count; ++j) { @@ -225,8 +321,8 @@ bool MessageStore::save_index() { // Serialize to string then write via OS abstraction (SPIFFS compatible) std::string json_str; - serializeJsonPretty(_json_doc, json_str); - Bytes data((const uint8_t*)json_str.data(), json_str.size()); + serializeJson(_json_doc, json_str); + Bytes data(reinterpret_cast(json_str.data()), json_str.size()); if (Utilities::OS::write_file(index_path.c_str(), data) != data.size()) { ERROR("Failed to write index file: " + index_path); @@ -252,36 +348,42 @@ bool MessageStore::save_message(const LXMessage& message) { INFO("Saving message: " + message.hash().toHex()); try { - // Use reusable document to reduce heap fragmentation - _json_doc.clear(); + const Bytes message_hash = message.hash(); + const std::string metadata_path = get_message_metadata_path(message_hash); + const std::string payload_path = get_message_payload_path(message_hash); + const std::string legacy_path = get_legacy_message_path(message_hash); + const std::string preview = make_message_preview(message.content()); - _json_doc["hash"] = message.hash().toHex(); + // Build compact metadata document only. Packed payload lives in a separate file. + _json_doc.clear(); + _json_doc["hash"] = message_hash.toHex(); _json_doc["destination_hash"] = message.destination_hash().toHex(); _json_doc["source_hash"] = message.source_hash().toHex(); _json_doc["incoming"] = message.incoming(); _json_doc["timestamp"] = message.timestamp(); _json_doc["state"] = static_cast(message.state()); + _json_doc["propagated"] = false; + _json_doc["content"] = std::string(reinterpret_cast(message.content().data()), message.content().size()); - // Store content as UTF-8 for fast loading (no msgpack unpacking needed) - std::string content_str((const char*)message.content().data(), message.content().size()); - _json_doc["content"] = content_str; - - // Store the entire packed message to preserve hash/signature - // This ensures exact reconstruction on load - _json_doc["packed"] = message.packed().toHex(); - - // Write message file via OS abstraction (SPIFFS compatible) - std::string message_path = get_message_path(message.hash()); - std::string json_str; - serializeJsonPretty(_json_doc, json_str); - Bytes data((const uint8_t*)json_str.data(), json_str.size()); + if (!write_json_document(metadata_path, _json_doc)) { + ERROR("Failed to write message metadata: " + metadata_path); + return false; + } - if (Utilities::OS::write_file(message_path.c_str(), data) != data.size()) { - ERROR("Failed to write message file: " + message_path); + const Bytes& packed = message.packed(); + if (!write_bytes_file(payload_path, packed)) { + ERROR("Failed to write message payload: " + payload_path); + Utilities::OS::remove_file(metadata_path.c_str()); return false; } - DEBUG(" Message file saved: " + message_path); + // Remove any legacy monolithic file so the split layout is canonical. + if (Utilities::OS::file_exists(legacy_path.c_str())) { + Utilities::OS::remove_file(legacy_path.c_str()); + } + + DEBUG(" Message metadata saved: " + metadata_path); + DEBUG(" Message payload saved: " + payload_path); // Update conversation index // Determine peer hash (the other party in the conversation) @@ -305,7 +407,8 @@ bool MessageStore::save_message(const LXMessage& message) { WARNING("Message pool full for conversation: " + peer_hash.toHex()); } else { conv.last_activity = message.timestamp(); - conv.set_last_message_hash(message.hash()); + conv.set_last_message_hash(message_hash); + conv.set_last_message_preview(preview); // Increment unread count for incoming messages if (message.incoming()) { @@ -335,43 +438,86 @@ LXMessage MessageStore::load_message(const Bytes& message_hash) { return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); } - std::string message_path = get_message_path(message_hash); - - if (!Utilities::OS::file_exists(message_path.c_str())) { - WARNING("Message file not found: " + message_path); - return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); - } + const std::string metadata_path = get_message_metadata_path(message_hash); + const std::string payload_path = get_message_payload_path(message_hash); + const std::string legacy_path = get_legacy_message_path(message_hash); try { - // Read JSON file via OS abstraction (SPIFFS compatible) - Bytes data; - if (Utilities::OS::read_file(message_path.c_str(), data) == 0) { - ERROR("Failed to read message file: " + message_path); - return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); - } + Bytes packed; + bool incoming = true; - // Use reusable document to reduce heap fragmentation - _json_doc.clear(); - DeserializationError error = deserializeJson(_json_doc, data.data(), data.size()); + if (Utilities::OS::file_exists(payload_path.c_str())) { + if (Utilities::OS::read_file(payload_path.c_str(), packed) == 0) { + ERROR("Failed to read message payload: " + payload_path); + return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); + } - if (error) { - ERROR("Failed to parse message file: " + std::string(error.c_str())); - return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); - } + if (Utilities::OS::file_exists(metadata_path.c_str())) { + Bytes metadata_bytes; + if (Utilities::OS::read_file(metadata_path.c_str(), metadata_bytes) > 0) { + _json_doc.clear(); + DeserializationError error = deserializeJson(_json_doc, metadata_bytes.data(), metadata_bytes.size()); + if (!error && !_json_doc["incoming"].isNull()) { + incoming = _json_doc["incoming"].as(); + } + } + } + } else { + std::string source_path = legacy_path; + if (!Utilities::OS::file_exists(source_path.c_str())) { + source_path = metadata_path; + } + if (!Utilities::OS::file_exists(source_path.c_str())) { + WARNING("Message file not found: " + source_path); + return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); + } - // Unpack the message from stored packed bytes - // This preserves the exact hash and signature - Bytes packed; - packed.assignHex(_json_doc["packed"].as()); + Bytes data; + if (Utilities::OS::read_file(source_path.c_str(), data) == 0) { + ERROR("Failed to read message file: " + source_path); + return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); + } - // Skip signature validation - messages from storage were already validated when received - LXMessage message = LXMessage::unpack_from_bytes(packed, LXMF::Type::Message::DIRECT, true); + _json_doc.clear(); + DeserializationError error = deserializeJson(_json_doc, data.data(), data.size()); + if (error) { + ERROR("Failed to parse message file: " + std::string(error.c_str())); + return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); + } + + if (!_json_doc["incoming"].isNull()) { + incoming = _json_doc["incoming"].as(); + } - // Restore incoming flag from storage (unpack_from_bytes defaults to true) - if (!_json_doc["incoming"].isNull()) { - message.incoming(_json_doc["incoming"].as()); + const char* packed_hex = _json_doc["packed"].as(); + if (!packed_hex || packed_hex[0] == '\0') { + ERROR("Message file missing packed payload: " + source_path); + return LXMessage(Bytes(), Bytes(), Bytes(), Bytes()); + } + + packed.assignHex(packed_hex); + + if (!Utilities::OS::file_exists(metadata_path.c_str())) { + JsonDocument metadata_doc; + metadata_doc["hash"] = _json_doc["hash"] | message_hash.toHex(); + metadata_doc["destination_hash"] = _json_doc["destination_hash"] | ""; + metadata_doc["source_hash"] = _json_doc["source_hash"] | ""; + metadata_doc["incoming"] = incoming; + metadata_doc["timestamp"] = _json_doc["timestamp"] | 0.0; + metadata_doc["state"] = _json_doc["state"] | 0; + metadata_doc["propagated"] = _json_doc["propagated"] | false; + if (_json_doc["content"].is()) { + metadata_doc["content"] = _json_doc["content"].as(); + } + if (write_json_document(metadata_path, metadata_doc)) { + write_bytes_file(payload_path, packed); + Utilities::OS::remove_file(legacy_path.c_str()); + } + } } + LXMessage message = LXMessage::unpack_from_bytes(packed, LXMF::Type::Message::DIRECT, true); + message.incoming(incoming); DEBUG("Loaded message: " + message_hash.toHex()); return message; @@ -390,35 +536,38 @@ MessageStore::MessageMetadata MessageStore::load_message_metadata(const Bytes& m return meta; } - std::string message_path = get_message_path(message_hash); - - if (!Utilities::OS::file_exists(message_path.c_str())) { - return meta; - } + const std::string metadata_path = get_message_metadata_path(message_hash); + const std::string legacy_path = get_legacy_message_path(message_hash); try { + std::string source_path; + if (Utilities::OS::file_exists(metadata_path.c_str())) { + source_path = metadata_path; + } else if (Utilities::OS::file_exists(legacy_path.c_str())) { + source_path = legacy_path; + } else { + return meta; + } + Bytes data; - if (Utilities::OS::read_file(message_path.c_str(), data) == 0) { + if (Utilities::OS::read_file(source_path.c_str(), data) == 0) { return meta; } - // Use reusable document to reduce heap fragmentation _json_doc.clear(); DeserializationError error = deserializeJson(_json_doc, data.data(), data.size()); - if (error) { return meta; } meta.hash = message_hash; - - // Read pre-extracted fields (no msgpack unpacking needed) if (_json_doc["content"].is()) { meta.content = _json_doc["content"].as(); } meta.timestamp = _json_doc["timestamp"] | 0.0; meta.incoming = _json_doc["incoming"] | true; meta.state = _json_doc["state"] | 0; + meta.propagated = _json_doc["propagated"] | false; meta.valid = true; return meta; @@ -428,29 +577,35 @@ MessageStore::MessageMetadata MessageStore::load_message_metadata(const Bytes& m } } -// Update message state in storage -bool MessageStore::update_message_state(const Bytes& message_hash, Type::Message::State state) { +bool MessageStore::update_message_metadata( + const Bytes& message_hash, + const Type::Message::State* state, + const bool* propagated +) { if (!_initialized) { ERROR("MessageStore not initialized"); return false; } - std::string message_path = get_message_path(message_hash); + const std::string metadata_path = get_message_metadata_path(message_hash); + const std::string payload_path = get_message_payload_path(message_hash); + const std::string legacy_path = get_legacy_message_path(message_hash); - if (!Utilities::OS::file_exists(message_path.c_str())) { - WARNING("Message file not found: " + message_path); + const bool has_metadata = Utilities::OS::file_exists(metadata_path.c_str()); + const bool has_legacy = Utilities::OS::file_exists(legacy_path.c_str()); + if (!has_metadata && !has_legacy) { + WARNING("Message file not found: " + metadata_path); return false; } try { - // Read existing JSON + const std::string source_path = has_metadata ? metadata_path : legacy_path; Bytes data; - if (Utilities::OS::read_file(message_path.c_str(), data) == 0) { - ERROR("Failed to read message file: " + message_path); + if (Utilities::OS::read_file(source_path.c_str(), data) == 0) { + ERROR("Failed to read message file: " + source_path); return false; } - // Use reusable document to reduce heap fragmentation _json_doc.clear(); DeserializationError error = deserializeJson(_json_doc, data.data(), data.size()); if (error) { @@ -458,26 +613,83 @@ bool MessageStore::update_message_state(const Bytes& message_hash, Type::Message return false; } - // Update state - _json_doc["state"] = static_cast(state); + bool update_migrated_layout = !has_metadata && has_legacy; + if (state) { + _json_doc["state"] = static_cast(*state); + } - // Write back - std::string json_str; - serializeJson(_json_doc, json_str); - if (!Utilities::OS::write_file(message_path.c_str(), Bytes((uint8_t*)json_str.c_str(), json_str.length()))) { - ERROR("Failed to write message file: " + message_path); + if (propagated) { + _json_doc["propagated"] = *propagated; + } + + if (update_migrated_layout) { + const char* packed_hex = _json_doc["packed"].as(); + if (packed_hex && packed_hex[0] != '\0') { + Bytes packed; + packed.assignHex(packed_hex); + + JsonDocument metadata_doc; + metadata_doc["hash"] = _json_doc["hash"] | message_hash.toHex(); + metadata_doc["destination_hash"] = _json_doc["destination_hash"] | ""; + metadata_doc["source_hash"] = _json_doc["source_hash"] | ""; + metadata_doc["incoming"] = _json_doc["incoming"] | true; + metadata_doc["timestamp"] = _json_doc["timestamp"] | 0.0; + metadata_doc["state"] = _json_doc["state"] | 0; + metadata_doc["propagated"] = _json_doc["propagated"] | false; + if (_json_doc["content"].is()) { + metadata_doc["content"] = _json_doc["content"].as(); + } + + if (!write_json_document(metadata_path, metadata_doc)) { + ERROR("Failed to write message metadata: " + metadata_path); + return false; + } + + if (!write_bytes_file(payload_path, packed)) { + ERROR("Failed to write message payload: " + payload_path); + return false; + } + + Utilities::OS::remove_file(legacy_path.c_str()); + } else { + ERROR("Legacy message missing packed payload: " + legacy_path); + return false; + } + } else if (!write_json_document(metadata_path, _json_doc)) { + ERROR("Failed to write message metadata: " + metadata_path); return false; } - INFO("Message state updated to " + std::to_string(static_cast(state))); + if (state && propagated) { + INFO("Message delivery metadata updated: state=" + + std::to_string(static_cast(*state)) + + ", propagated=" + std::string(*propagated ? "true" : "false")); + } else if (state) { + INFO("Message state updated to " + std::to_string(static_cast(*state))); + } else if (propagated) { + INFO("Message propagated flag updated to " + std::string(*propagated ? "true" : "false")); + } return true; } catch (const std::exception& e) { - ERROR("Exception updating message state: " + std::string(e.what())); + ERROR("Exception updating message metadata: " + std::string(e.what())); return false; } } +// Update message state in storage +bool MessageStore::update_message_state(const Bytes& message_hash, Type::Message::State state) { + return update_message_metadata(message_hash, &state, nullptr); +} + +bool MessageStore::update_message_delivery_status( + const Bytes& message_hash, + Type::Message::State state, + bool propagated +) { + return update_message_metadata(message_hash, &state, &propagated); +} + // Delete message from storage bool MessageStore::delete_message(const Bytes& message_hash) { if (!_initialized) { @@ -487,13 +699,23 @@ bool MessageStore::delete_message(const Bytes& message_hash) { INFO("Deleting message: " + message_hash.toHex()); - // Remove message file - std::string message_path = get_message_path(message_hash); - if (Utilities::OS::file_exists(message_path.c_str())) { - if (!Utilities::OS::remove_file(message_path.c_str())) { - ERROR("Failed to delete message file: " + message_path); - return false; - } + // Remove message files (split metadata/payload plus legacy monolith) + const std::string metadata_path = get_message_metadata_path(message_hash); + const std::string payload_path = get_message_payload_path(message_hash); + const std::string legacy_path = get_legacy_message_path(message_hash); + + if (Utilities::OS::file_exists(metadata_path.c_str()) && + !Utilities::OS::remove_file(metadata_path.c_str())) { + ERROR("Failed to delete message metadata: " + metadata_path); + return false; + } + if (Utilities::OS::file_exists(payload_path.c_str()) && + !Utilities::OS::remove_file(payload_path.c_str())) { + ERROR("Failed to delete message payload: " + payload_path); + return false; + } + if (Utilities::OS::file_exists(legacy_path.c_str())) { + Utilities::OS::remove_file(legacy_path.c_str()); } // Update conversation index - remove from all conversations @@ -548,12 +770,41 @@ std::vector MessageStore::get_conversations() { } // Get conversation info -MessageStore::ConversationInfo MessageStore::get_conversation_info(const Bytes& peer_hash) { +const MessageStore::ConversationInfo* MessageStore::get_conversation_info(const Bytes& peer_hash) const { const ConversationSlot* slot = find_conversation(peer_hash); if (slot) { - return slot->info; + return &slot->info; } - return ConversationInfo(); + return nullptr; +} + +std::vector MessageStore::get_conversation_summaries() const { + std::vector summaries; + summaries.reserve(count_conversations()); + + for (size_t i = 0; i < MAX_CONVERSATIONS; ++i) { + const ConversationSlot& slot = _conversations_pool[i]; + if (!slot.in_use) { + continue; + } + + ConversationSummary summary; + summary.peer_hash = slot.peer_hash_bytes(); + summary.last_activity = slot.info.last_activity; + summary.unread_count = static_cast(slot.info.unread_count); + summary.last_message_preview = slot.info.last_message_preview_cstr(); + summaries.push_back(summary); + } + + std::sort( + summaries.begin(), + summaries.end(), + [](const ConversationSummary& a, const ConversationSummary& b) { + return a.last_activity > b.last_activity; + } + ); + + return summaries; } // Get messages for conversation @@ -592,9 +843,18 @@ bool MessageStore::delete_conversation(const Bytes& peer_hash) { // Delete all message files for (size_t i = 0; i < slot->info.message_count; ++i) { - std::string message_path = get_message_path(slot->info.message_hash_bytes(i)); - if (Utilities::OS::file_exists(message_path.c_str())) { - Utilities::OS::remove_file(message_path.c_str()); + const Bytes hash = slot->info.message_hash_bytes(i); + const std::string metadata_path = get_message_metadata_path(hash); + const std::string payload_path = get_message_payload_path(hash); + const std::string legacy_path = get_legacy_message_path(hash); + if (Utilities::OS::file_exists(metadata_path.c_str())) { + Utilities::OS::remove_file(metadata_path.c_str()); + } + if (Utilities::OS::file_exists(payload_path.c_str())) { + Utilities::OS::remove_file(payload_path.c_str()); + } + if (Utilities::OS::file_exists(legacy_path.c_str())) { + Utilities::OS::remove_file(legacy_path.c_str()); } } @@ -644,9 +904,18 @@ bool MessageStore::clear_all() { continue; } for (size_t j = 0; j < slot.info.message_count; ++j) { - std::string message_path = get_message_path(slot.info.message_hash_bytes(j)); - if (Utilities::OS::file_exists(message_path.c_str())) { - Utilities::OS::remove_file(message_path.c_str()); + const Bytes hash = slot.info.message_hash_bytes(j); + const std::string metadata_path = get_message_metadata_path(hash); + const std::string payload_path = get_message_payload_path(hash); + const std::string legacy_path = get_legacy_message_path(hash); + if (Utilities::OS::file_exists(metadata_path.c_str())) { + Utilities::OS::remove_file(metadata_path.c_str()); + } + if (Utilities::OS::file_exists(payload_path.c_str())) { + Utilities::OS::remove_file(payload_path.c_str()); + } + if (Utilities::OS::file_exists(legacy_path.c_str())) { + Utilities::OS::remove_file(legacy_path.c_str()); } } slot.clear(); @@ -663,6 +932,18 @@ bool MessageStore::clear_all() { // Use short path for SPIFFS compatibility (32 char filename limit) // Format: /m/.j (12 chars of hash = 6 bytes = plenty unique for local store) std::string MessageStore::get_message_path(const Bytes& message_hash) const { + return get_legacy_message_path(message_hash); +} + +std::string MessageStore::get_message_metadata_path(const Bytes& message_hash) const { + return "/m/" + message_hash.toHex().substr(0, 12) + ".m"; +} + +std::string MessageStore::get_message_payload_path(const Bytes& message_hash) const { + return "/m/" + message_hash.toHex().substr(0, 12) + ".p"; +} + +std::string MessageStore::get_legacy_message_path(const Bytes& message_hash) const { return "/m/" + message_hash.toHex().substr(0, 12) + ".j"; } diff --git a/src/LXMF/MessageStore.h b/src/LXMF/MessageStore.h index d0f7bf34..4c901b73 100644 --- a/src/LXMF/MessageStore.h +++ b/src/LXMF/MessageStore.h @@ -4,8 +4,16 @@ #include "../Bytes.h" #include +#include +#include +#include #include #include +#include + +#ifdef ARDUINO +#include +#endif namespace LXMF { @@ -14,6 +22,7 @@ namespace LXMF { static constexpr size_t MAX_MESSAGES_PER_CONVERSATION = 256; static constexpr size_t MESSAGE_HASH_SIZE = 32; // SHA256 hash static constexpr size_t PEER_HASH_SIZE = 16; // Truncated hash + static constexpr size_t MESSAGE_PREVIEW_SIZE = 64; /** * @brief Message persistence and conversation management for LXMF @@ -24,7 +33,9 @@ namespace LXMF { * Storage structure: * / * conversations.json - Conversation index - * messages/.json - Individual message files + * messages/.m - Message metadata (content, state, timestamps) + * messages/.p - Packed LXMF payload bytes + * messages/.j - Legacy monolithic file (read/migrate only) * conversations// - Per-conversation metadata * * Usage: @@ -50,6 +61,7 @@ namespace LXMF { double last_activity = 0.0; // Timestamp of most recent message size_t unread_count = 0; // Number of unread messages uint8_t last_message_hash[MESSAGE_HASH_SIZE]; + char last_message_preview[MESSAGE_PREVIEW_SIZE]; // Helper methods for accessing fixed arrays as Bytes RNS::Bytes peer_hash_bytes() const { return RNS::Bytes(peer_hash, PEER_HASH_SIZE); } @@ -58,6 +70,7 @@ namespace LXMF { return RNS::Bytes(message_hashes[idx], MESSAGE_HASH_SIZE); } RNS::Bytes last_message_hash_bytes() const { return RNS::Bytes(last_message_hash, MESSAGE_HASH_SIZE); } + const char* last_message_preview_cstr() const { return last_message_preview; } void set_peer_hash(const RNS::Bytes& b) { size_t len = std::min(b.size(), PEER_HASH_SIZE); @@ -69,6 +82,9 @@ namespace LXMF { memcpy(last_message_hash, b.data(), len); if (len < MESSAGE_HASH_SIZE) memset(last_message_hash + len, 0, MESSAGE_HASH_SIZE - len); } + void set_last_message_preview(const std::string& preview) { + snprintf(last_message_preview, sizeof(last_message_preview), "%s", preview.c_str()); + } bool peer_hash_equals(const RNS::Bytes& b) const { if (b.size() != PEER_HASH_SIZE) return false; return memcmp(peer_hash, b.data(), PEER_HASH_SIZE) == 0; @@ -101,6 +117,30 @@ namespace LXMF { void clear(); }; + struct ConversationSlot; + + struct ConversationPoolDeleter { + bool psram_allocated = false; + + ConversationPoolDeleter() noexcept = default; + explicit ConversationPoolDeleter(bool from_psram) noexcept : psram_allocated(from_psram) {} + + void operator()(ConversationSlot* ptr) const noexcept { + if (!ptr) { + return; + } +#ifdef ARDUINO + if (psram_allocated) { + heap_caps_free(ptr); + } else { + std::free(ptr); + } +#else + std::free(ptr); +#endif + } + }; + /** * @brief Fixed-size slot for conversation storage */ @@ -139,9 +179,17 @@ namespace LXMF { double timestamp; bool incoming; int state; // Type::Message::State as int + bool propagated = false; bool valid; // True if loaded successfully }; + struct ConversationSummary { + RNS::Bytes peer_hash; + double last_activity = 0.0; + uint16_t unread_count = 0; + std::string last_message_preview; + }; + public: /** * @brief Construct MessageStore @@ -175,8 +223,9 @@ namespace LXMF { /** * @brief Load only message metadata (fast path for chat list) * - * Reads content/timestamp/state directly from JSON without msgpack unpacking. - * Much faster than load_message() for displaying message lists. + * Reads content/timestamp/state directly from compact metadata storage + * without unpacking the LXMF payload. Much faster than load_message() + * for displaying message lists. * * @param message_hash Hash of the message to load * @return MessageMetadata struct (check .valid field) @@ -194,6 +243,22 @@ namespace LXMF { */ bool update_message_state(const RNS::Bytes& message_hash, Type::Message::State state); + /** + * @brief Update outgoing delivery status in storage + * + * Updates the state and propagated flag together in a single file rewrite. + * + * @param message_hash Hash of the message to update + * @param state New state value + * @param propagated True if accepted by propagation node + * @return True if updated successfully + */ + bool update_message_delivery_status( + const RNS::Bytes& message_hash, + Type::Message::State state, + bool propagated + ); + /** * @brief Delete a message from storage * @@ -219,7 +284,15 @@ namespace LXMF { * @param peer_hash Hash of the peer * @return ConversationInfo (or empty if not found) */ - ConversationInfo get_conversation_info(const RNS::Bytes& peer_hash); + const ConversationInfo* get_conversation_info(const RNS::Bytes& peer_hash) const; + + /** + * @brief Get lightweight summaries for all conversations + * + * Returns conversations sorted by last activity (most recent first) using + * only the in-memory index data needed for list rendering. + */ + std::vector get_conversation_summaries() const; /** * @brief Get all message hashes for a conversation @@ -312,6 +385,25 @@ namespace LXMF { */ std::string get_message_path(const RNS::Bytes& message_hash) const; + /** + * @brief Get compact metadata path for a message + * + * New-format metadata is stored separately from packed LXMF payload. + */ + std::string get_message_metadata_path(const RNS::Bytes& message_hash) const; + + /** + * @brief Get packed payload path for a message + */ + std::string get_message_payload_path(const RNS::Bytes& message_hash) const; + + /** + * @brief Get legacy single-file message path + * + * Used only for backward-compatible reads and migration. + */ + std::string get_legacy_message_path(const RNS::Bytes& message_hash) const; + /** * @brief Get filesystem path for conversation directory * @@ -356,9 +448,21 @@ namespace LXMF { */ size_t count_conversations() const; + /** + * @brief Shared metadata update helper for message JSON files + * + * Updates one or both of the persisted state/propagated fields in a single + * read/modify/write cycle. + */ + bool update_message_metadata( + const RNS::Bytes& message_hash, + const Type::Message::State* state, + const bool* propagated + ); + private: std::string _base_path; - ConversationSlot _conversations_pool[MAX_CONVERSATIONS]; + std::unique_ptr _conversations_pool; bool _initialized; // Reusable JSON document to reduce heap fragmentation diff --git a/src/LXMF/PropagationNodeManager.cpp b/src/LXMF/PropagationNodeManager.cpp index ee14265c..4dce5524 100644 --- a/src/LXMF/PropagationNodeManager.cpp +++ b/src/LXMF/PropagationNodeManager.cpp @@ -19,6 +19,7 @@ void PropagationNodeManager::received_announce( const Identity& announced_identity, const Bytes& app_data ) { + (void)announced_identity; std::string hash_str = destination_hash.toHex().substr(0, 16); TRACE("PropagationNodeManager::received_announce from " + hash_str + "..."); @@ -35,6 +36,8 @@ void PropagationNodeManager::received_announce( info.node_hash = destination_hash; info.last_seen = Utilities::OS::time(); + Bytes effective_before = get_effective_node(); + PropagationNodeInfo effective_before_info = get_node(effective_before); // Get hop count from Transport info.hops = Transport::hops_to(destination_hash); @@ -48,8 +51,15 @@ void PropagationNodeManager::received_announce( if (!slot) { slot = find_empty_node_slot(); if (!slot) { - WARNING("PropagationNodeManager: Pool full, cannot add node " + hash_str); - return; + PropagationNodeSlot* worst_slot = find_worst_node_slot(); + if (!worst_slot || !is_better_candidate(info, worst_slot->info)) { + WARNING("PropagationNodeManager: Pool full, dropping lower-priority node " + hash_str); + return; + } + + INFO("PropagationNodeManager: Evicting node " + + worst_slot->node_hash.toHex().substr(0, 16) + "... for better candidate " + hash_str); + slot = worst_slot; } } @@ -68,8 +78,21 @@ void PropagationNodeManager::received_announce( "... reports propagation disabled"); } + update_effective_node(false); + // Notify listeners - if (_update_callback) { + Bytes effective_after = get_effective_node(); + PropagationNodeInfo effective_after_info = get_node(effective_after); + bool effective_changed = (effective_after != effective_before); + bool effective_info_changed = + (effective_after == effective_before) && + ( + effective_after_info.stamp_cost != effective_before_info.stamp_cost || + effective_after_info.enabled != effective_before_info.enabled || + effective_after_info.hops != effective_before_info.hops + ); + + if (_update_callback && (effective_changed || effective_info_changed)) { _update_callback(); } } @@ -132,24 +155,21 @@ PropagationNodeInfo PropagationNodeManager::parse_announce_data(const Bytes& app unpacker.deserialize(key); if (key == PN_META_NAME) { - // Name is a binary/string + // Python packs name as bytes (bin type via str.encode("utf-8")) MsgPack::bin_t name_bin; unpacker.deserialize(name_bin); - info.name = std::string(name_bin.begin(), name_bin.end()); - } else { - // Skip other metadata fields by reading and discarding - // Try to read as binary first (most common), fall back to int - try { - MsgPack::bin_t skip_bin; - unpacker.deserialize(skip_bin); - } catch (...) { - try { - int64_t skip_int; - unpacker.deserialize(skip_int); - } catch (...) { - // Give up - might be complex type - } + if (!name_bin.empty()) { + info.name = std::string(name_bin.begin(), name_bin.end()); } + // On type mismatch (e.g. str type from non-standard node), + // type_error() silently advances curr_index past the element. + // Name stays empty and will use default fallback below. + } else { + // Skip unknown metadata value: deserialize as int64_t. + // On type mismatch, type_error() increments curr_index by 1, + // which correctly skips to the next element regardless of type. + int64_t skip_val; + unpacker.deserialize(skip_val); } } @@ -202,9 +222,15 @@ bool PropagationNodeManager::has_node(const Bytes& hash) const { } void PropagationNodeManager::set_selected_node(const Bytes& hash) { + Bytes effective_before = get_effective_node(); + if (hash.size() == 0) { - _selected_node = {}; + _manual_selected_node = {}; + update_effective_node(true); INFO("PropagationNodeManager: Cleared manual node selection"); + if (_update_callback && get_effective_node() != effective_before) { + _update_callback(); + } return; } @@ -213,10 +239,14 @@ void PropagationNodeManager::set_selected_node(const Bytes& hash) { return; } - _selected_node = hash; + _manual_selected_node = hash; + update_effective_node(true); PropagationNodeInfo node = get_node(hash); INFO("PropagationNodeManager: Selected node '" + node.name + "' (" + hash.toHex().substr(0, 16) + "...)"); + if (_update_callback && get_effective_node() != effective_before) { + _update_callback(); + } } Bytes PropagationNodeManager::get_best_node() const { @@ -230,8 +260,7 @@ Bytes PropagationNodeManager::get_best_node() const { } const PropagationNodeInfo& node = _nodes_pool[i].info; - // Skip disabled nodes - if (!node.enabled) { + if (!is_reachable_candidate(node)) { continue; } @@ -241,6 +270,8 @@ Bytes PropagationNodeManager::get_best_node() const { is_better = true; } else if (node.hops == best.hops && node.last_seen > best.last_seen) { is_better = true; + } else if (node.hops == best.hops && node.last_seen == best.last_seen && node.stamp_cost < best.stamp_cost) { + is_better = true; } if (is_better) { @@ -257,21 +288,27 @@ Bytes PropagationNodeManager::get_best_node() const { } Bytes PropagationNodeManager::get_effective_node() const { - if (_selected_node.size() > 0) { - // Verify selected node is still valid - const PropagationNodeSlot* slot = find_node_slot(_selected_node); - if (slot && slot->info.enabled) { - return _selected_node; + if (_manual_selected_node.size() > 0) { + const PropagationNodeSlot* slot = find_node_slot(_manual_selected_node); + if (slot) { + return _manual_selected_node; + } + } + + if (_effective_node.size() > 0) { + const PropagationNodeSlot* slot = find_node_slot(_effective_node); + if (slot) { + return _effective_node; } } - // Fall back to auto-selection return get_best_node(); } void PropagationNodeManager::clean_stale_nodes() { double now = Utilities::OS::time(); bool removed_any = false; + Bytes effective_before = get_effective_node(); for (size_t i = 0; i < MAX_PROPAGATION_NODES; ++i) { if (_nodes_pool[i].in_use && @@ -283,7 +320,11 @@ void PropagationNodeManager::clean_stale_nodes() { } } - if (removed_any && _update_callback) { + if (removed_any) { + update_effective_node(true); + } + + if (removed_any && _update_callback && get_effective_node() != effective_before) { _update_callback(); } } @@ -315,6 +356,26 @@ PropagationNodeSlot* PropagationNodeManager::find_empty_node_slot() { return nullptr; } +PropagationNodeSlot* PropagationNodeManager::find_worst_node_slot() { + PropagationNodeSlot* worst = nullptr; + + for (size_t i = 0; i < MAX_PROPAGATION_NODES; ++i) { + if (!_nodes_pool[i].in_use) { + continue; + } + if (!worst) { + worst = &_nodes_pool[i]; + continue; + } + + if (is_better_candidate(worst->info, _nodes_pool[i].info)) { + worst = &_nodes_pool[i]; + } + } + + return worst; +} + size_t PropagationNodeManager::nodes_count() const { size_t count = 0; for (size_t i = 0; i < MAX_PROPAGATION_NODES; ++i) { @@ -324,3 +385,97 @@ size_t PropagationNodeManager::nodes_count() const { } return count; } + +bool PropagationNodeManager::is_better_candidate(const PropagationNodeInfo& candidate, const PropagationNodeInfo& current) const { + if (!candidate.enabled && current.enabled) { + return false; + } + if (candidate.enabled && !current.enabled) { + return true; + } + if (candidate.hops != current.hops) { + return candidate.hops < current.hops; + } + if (candidate.last_seen != current.last_seen) { + return candidate.last_seen > current.last_seen; + } + return candidate.stamp_cost < current.stamp_cost; +} + +bool PropagationNodeManager::is_reachable_candidate(const PropagationNodeInfo& candidate) const { + if (!candidate || !candidate.enabled) { + return false; + } + + if (!Transport::has_path(candidate.node_hash)) { + return false; + } + + Identity identity = Identity::recall(candidate.node_hash); + return static_cast(identity); +} + +bool PropagationNodeManager::should_switch_effective_node( + const PropagationNodeInfo& current, + const PropagationNodeInfo& candidate, + bool force_reselect +) const { + if (!candidate || !is_reachable_candidate(candidate)) { + return false; + } + if (!current) { + return true; + } + if (!is_reachable_candidate(current)) { + return true; + } + if (force_reselect) { + return is_better_candidate(candidate, current); + } + + double now = Utilities::OS::time(); + double current_age = now - current.last_seen; + if (current_age > EFFECTIVE_NODE_STICKY_WINDOW) { + return is_better_candidate(candidate, current); + } + + if (candidate.hops + EFFECTIVE_NODE_SWITCH_HOP_MARGIN <= current.hops) { + return true; + } + + return false; +} + +void PropagationNodeManager::update_effective_node(bool force_reselect) { + if (_manual_selected_node.size() > 0) { + const PropagationNodeSlot* manual_slot = find_node_slot(_manual_selected_node); + if (manual_slot) { + _effective_node = _manual_selected_node; + return; + } + } + + if (_effective_node.size() > 0) { + const PropagationNodeSlot* current_slot = find_node_slot(_effective_node); + if (current_slot) { + Bytes best_hash = get_best_node(); + if (best_hash.size() == 0) { + return; + } + + const PropagationNodeSlot* best_slot = find_node_slot(best_hash); + if (!best_slot) { + return; + } + + if (!should_switch_effective_node(current_slot->info, best_slot->info, force_reselect)) { + return; + } + } + } + + Bytes best_hash = get_best_node(); + if (best_hash.size() > 0) { + _effective_node = best_hash; + } +} diff --git a/src/LXMF/PropagationNodeManager.h b/src/LXMF/PropagationNodeManager.h index 71278334..5c92c504 100644 --- a/src/LXMF/PropagationNodeManager.h +++ b/src/LXMF/PropagationNodeManager.h @@ -83,6 +83,8 @@ namespace LXMF { // Stale node timeout (1 hour) static constexpr double NODE_STALE_TIMEOUT = 3600.0; + static constexpr double EFFECTIVE_NODE_STICKY_WINDOW = 120.0; + static constexpr uint8_t EFFECTIVE_NODE_SWITCH_HOP_MARGIN = 2; public: /** @@ -146,7 +148,7 @@ namespace LXMF { * * @return Destination hash of selected node (or empty if none) */ - RNS::Bytes get_selected_node() const { return _selected_node; } + RNS::Bytes get_selected_node() const { return _manual_selected_node; } /** * @brief Auto-select the best available propagation node @@ -216,6 +218,7 @@ namespace LXMF { * @return Pointer to empty slot, nullptr if pool is full */ PropagationNodeSlot* find_empty_node_slot(); + PropagationNodeSlot* find_worst_node_slot(); /** * @brief Get the number of nodes currently in use @@ -223,10 +226,15 @@ namespace LXMF { * @return Number of active nodes in the pool */ size_t nodes_count() const; + bool is_better_candidate(const PropagationNodeInfo& candidate, const PropagationNodeInfo& current) const; + bool is_reachable_candidate(const PropagationNodeInfo& candidate) const; + bool should_switch_effective_node(const PropagationNodeInfo& current, const PropagationNodeInfo& candidate, bool force_reselect) const; + void update_effective_node(bool force_reselect = false); private: PropagationNodeSlot _nodes_pool[MAX_PROPAGATION_NODES]; - RNS::Bytes _selected_node; + RNS::Bytes _manual_selected_node; + RNS::Bytes _effective_node; NodeUpdateCallback _update_callback; }; diff --git a/src/LXMF/Type.h b/src/LXMF/Type.h index 3792c0c0..22345dd8 100644 --- a/src/LXMF/Type.h +++ b/src/LXMF/Type.h @@ -43,7 +43,7 @@ namespace LXMF { */ enum Method : uint8_t { OPPORTUNISTIC = 0x01, ///< Single packet, fire-and-forget - DIRECT = 0x02, ///< Via established link (Phase 1 MVP) + DIRECT = 0x02, ///< Via established link PROPAGATED = 0x03, ///< Store-and-forward via propagation nodes PAPER = 0x05 ///< QR code / paper-based transfer }; @@ -96,6 +96,13 @@ namespace LXMF { // With an MTU of 500, encrypted packet MDU is 391 bytes static const uint16_t ENCRYPTED_PACKET_MDU = RNS::Type::Packet::ENCRYPTED_MDU + TIMESTAMP_SIZE; // 391 bytes + // LoRa-constrained ENCRYPTED_PACKET_MDU (for devices with LoRa interfaces) + // LoRa wire MTU=255, SINGLE header=19, IFAC=1 → max encrypted payload=235 + // Subtract ephemeral key(32) + IV(16) + HMAC(32) = 155 bytes for ciphertext + // Max AES blocks: floor(155/16)=9 → max padded plaintext=144 → max plaintext=143 + // Add back destination hash(16) stripped in OPPORTUNISTIC: max packed_size=159 + static const uint16_t LORA_ENCRYPTED_PACKET_MDU = 159; + /** * @brief Max content in single encrypted packet: 295 bytes * @@ -111,14 +118,27 @@ namespace LXMF { */ static const uint16_t LINK_PACKET_MDU = RNS::Type::Link::MDU; + // LoRa-constrained LINK_PACKET_MDU (for devices with LoRa interfaces) + // LoRa wire MTU=255, SX1262 adds 1 IFAC byte → max at transmit=254 + // Link header=19 bytes (2 flags + 1 context + 16 link_id) + // Max Token output = 254 - 19 = 235 bytes + // Token = IV(16) + ciphertext + HMAC(32), max ciphertext = 235 - 48 = 187 + // Max AES blocks: floor(187/16)=11 → max padded ciphertext=176 + // Max plaintext: 175 (PKCS7: 175 mod 16 = 15, pad 1 → 176) + static const uint16_t LORA_LINK_PACKET_MDU = 175; + /** - * @brief Max content in single link packet: 319 bytes (Phase 1 MVP limit) + * @brief Max content in single link packet: 319 bytes * * Calculation: LINK_PACKET_MDU - LXMF_OVERHEAD * Messages larger than 319 bytes will use Resource transfer. */ static const uint16_t LINK_PACKET_MAX_CONTENT = LINK_PACKET_MDU - LXMF_OVERHEAD; + // LoRa-constrained: max content in single link packet = 175 - 112 = 63 bytes + // Messages with content > 63 bytes sent over links will use Resource transfer + static const uint16_t LORA_LINK_PACKET_MAX_CONTENT = LORA_LINK_PACKET_MDU - LXMF_OVERHEAD; + // Plain (unencrypted) packet MDU static const uint16_t PLAIN_PACKET_MDU = RNS::Type::Packet::PLAIN_MDU; diff --git a/src/Link.cpp b/src/Link.cpp index 88e9b41b..f45ff5ea 100644 --- a/src/Link.cpp +++ b/src/Link.cpp @@ -18,6 +18,63 @@ #define MSGPACK_DEBUGLOG_ENABLE 0 #include +// Helper: parse msgpack [request_id, response_data] without type constraints. +// The request_id is always BIN (hash bytes). The response_data can be ANY +// msgpack type (array, int, bin, etc.) — we return its raw bytes for the +// caller to parse. This is needed for Python interop where response_data +// is a native msgpack array, not BIN-wrapped. +// Returns byte offset past request_id (>0 on success, 0 on failure). +static size_t parse_response_array(const RNS::Bytes& packed, RNS::Bytes& request_id_out, RNS::Bytes& response_data_out) { + const uint8_t* p = packed.data(); + size_t total = packed.size(); + if (total < 3) return 0; // Minimum: fixarray(2) + 1 byte + 1 byte + + size_t pos = 0; + + // 1. Parse array header — expect fixarray of 2 (0x92) or array16/array32 + uint8_t header = p[pos++]; + size_t arr_size = 0; + if ((header & 0xf0) == 0x90) { + arr_size = header & 0x0f; // fixarray + } else if (header == 0xdc && pos + 2 <= total) { + arr_size = ((size_t)p[pos] << 8) | p[pos+1]; // array16 + pos += 2; + } else if (header == 0xdd && pos + 4 <= total) { + arr_size = ((size_t)p[pos] << 24) | ((size_t)p[pos+1] << 16) | ((size_t)p[pos+2] << 8) | p[pos+3]; // array32 + pos += 4; + } else { + return 0; // Not an array + } + if (arr_size < 2) return 0; + + // 2. Parse element [0]: request_id (BIN type — hash bytes) + if (pos >= total) return 0; + uint8_t bin_header = p[pos++]; + size_t bin_len = 0; + if (bin_header == 0xc4 && pos + 1 <= total) { // bin8 + bin_len = p[pos++]; + } else if (bin_header == 0xc5 && pos + 2 <= total) { // bin16 + bin_len = ((size_t)p[pos] << 8) | p[pos+1]; + pos += 2; + } else if (bin_header == 0xc6 && pos + 4 <= total) { // bin32 + bin_len = ((size_t)p[pos] << 24) | ((size_t)p[pos+1] << 16) | ((size_t)p[pos+2] << 8) | p[pos+3]; + pos += 4; + } else { + return 0; // request_id not BIN type + } + if (pos + bin_len > total) return 0; + request_id_out = RNS::Bytes(p + pos, bin_len); + pos += bin_len; + + // 3. Remaining bytes are the raw msgpack of element [1] (response_data) + if (pos >= total) { + response_data_out = RNS::Bytes(); + } else { + response_data_out = RNS::Bytes(p + pos, total - pos); + } + return pos; +} + #include #include @@ -192,6 +249,23 @@ Link::Link(const Destination& destination /*= {Type::NONE}*/, Callbacks::establi Link link({Type::NONE}, nullptr, nullptr, owner, data.left(ECPUBSIZE/2), data.mid(ECPUBSIZE/2, ECPUBSIZE/2)); INFO(">>> Link::validate_request step 2: setting link_id"); link.set_link_id(packet); + + if (data.size() == ECPUBSIZE + LINK_MTU_SIZE) { + DEBUG("Link request includes MTU signalling"); + try { + uint16_t mtu = mtu_from_lr_packet(packet); + link.mtu((mtu != 0) ? mtu : Type::Reticulum::MTU); + } + catch (std::exception& e) { + ERRORF("An error occurred while validating link request %s", link.link_id().toHex().c_str()); + link.mtu(Type::Reticulum::MTU); + } + } + + link.mode(mode_from_lr_packet(packet)); + DEBUGF("Incoming link request with mode %d", link.get_mode()); + link.update_mdu(); + link.destination(packet.destination()); link.establishment_timeout(ESTABLISHMENT_TIMEOUT_PER_HOP * std::max((uint8_t)1, packet.hops()) + KEEPALIVE); link.establishment_cost(link.establishment_cost() + packet.raw().size()); @@ -220,7 +294,7 @@ Link::Link(const Destination& destination /*= {Type::NONE}*/, Callbacks::establi } } else { - DEBUG("Invalid link request payload size, dropping request"); + DEBUGF("Invalid link request payload size (%zu), dropping request", data.size()); return {Type::NONE}; } } @@ -280,12 +354,20 @@ void Link::prove() { INFO(">>> prove(): entry"); DEBUGF("Link %s requesting proof", link_id().toHex().c_str()); INFO(">>> prove(): preparing signed_data"); - Bytes signed_data =_object->_link_id + _object->_pub_bytes + _object->_sig_pub_bytes; + // Cap link MTU at interface HW_MTU for constrained interfaces (e.g. LoRa 255 bytes) + if (_object->_attached_interface && + (_object->_attached_interface.AUTOCONFIGURE_MTU() || _object->_attached_interface.FIXED_MTU()) && + _object->_attached_interface.HW_MTU() < _object->_mtu) { + DEBUGF("Capping link MTU from %d to interface HW_MTU %d", _object->_mtu, _object->_attached_interface.HW_MTU()); + _object->_mtu = _object->_attached_interface.HW_MTU(); + } + Bytes signalling_bytes = Link::signalling_bytes(_object->_mtu, _object->_mode); + Bytes signed_data =_object->_link_id + _object->_pub_bytes + _object->_sig_pub_bytes + signalling_bytes; INFO(">>> prove(): calling identity.sign()"); const Bytes signature(_object->_owner.identity().sign(signed_data)); INFO(">>> prove(): signature complete, building proof packet"); - Bytes proof_data = signature + _object->_pub_bytes; + Bytes proof_data = signature + _object->_pub_bytes + signalling_bytes; // CBA LINK // CBA TODO: Determine which approach is better, passing liunk to packet or passing _link_destination INFO(">>> prove(): creating Packet"); @@ -346,7 +428,7 @@ void Link::validate_proof(const Packet& packet) { handshake(); _object->_establishment_cost += packet.raw().size(); - Bytes signed_data = _object->_link_id + _object->_peer_pub_bytes + _object->_peer_sig_pub_bytes; + Bytes signed_data = _object->_link_id + _object->_peer_pub_bytes + _object->_peer_sig_pub_bytes + signalling_bytes; const Bytes signature(packet_data.left(Type::Identity::SIGLENGTH/8)); TRACEF("Link %s validating identity", link_id().toHex().c_str()); @@ -359,27 +441,35 @@ void Link::validate_proof(const Packet& packet) { _object->__remote_identity = _object->_destination.identity(); if (confirmed_mtu) _object->_mtu = confirmed_mtu; else _object->_mtu = RNS::Type::Reticulum::MTU; + // Cap link MTU at interface HW_MTU for constrained interfaces (e.g. LoRa 255 bytes) + if (_object->_attached_interface && + (_object->_attached_interface.AUTOCONFIGURE_MTU() || _object->_attached_interface.FIXED_MTU()) && + _object->_attached_interface.HW_MTU() < _object->_mtu) { + DEBUGF("Capping link MTU from %d to interface HW_MTU %d", _object->_mtu, _object->_attached_interface.HW_MTU()); + _object->_mtu = _object->_attached_interface.HW_MTU(); + } update_mdu(); _object->_status = Type::Link::ACTIVE; _object->_activated_at = OS::time(); _object->_last_proof = _object->_activated_at; + // Save a local copy before activate_link, which removes the + // link from the pending pool and invalidates *this. + Link self(*this); Transport::activate_link(*this); - std::string link_str = toString(); - std::string dest_str = _object->_destination.toString(); - VERBOSEF("Link %s established with %s, RTT is %f s", link_str.c_str(), dest_str.c_str(), OS::round(_object->_rtt, 3)); - - //p if _object->_rtt != None and _object->_establishment_cost != None and _object->_rtt > 0 and _object->_establishment_cost > 0: - if (_object->_rtt != 0.0 && _object->_establishment_cost != 0 && _object->_rtt > 0 and _object->_establishment_cost > 0) { - _object->_establishment_rate = _object->_establishment_cost / _object->_rtt; + // After activate_link, *this and _object are invalid - use self. + VERBOSEF("Link %s established with %s, RTT is %f s", + self.toString().c_str(), self.destination().toString().c_str(), + OS::round(self._object->_rtt, 3)); + + if (self._object->_rtt != 0.0 && self._object->_establishment_cost != 0 && self._object->_rtt > 0 && self._object->_establishment_cost > 0) { + self._object->_establishment_rate = self._object->_establishment_cost / self._object->_rtt; } - //p rtt_data = umsgpack.packb(self.rtt) MsgPack::Packer packer; - packer.serialize(_object->_rtt); + packer.serialize(self._object->_rtt); Bytes rtt_data(packer.data(), packer.size()); TRACEF("***** RTT data size: %d", rtt_data.size()); - //p rtt_packet = RNS.Packet(self, rtt_data, context=RNS.Packet.LRRTT) - Packet rtt_packet(*this, rtt_data, Type::Packet::DATA, Type::Packet::LRRTT); + Packet rtt_packet(self, rtt_data, Type::Packet::DATA, Type::Packet::LRRTT); TRACEF("***** RTT packet data: %s", rtt_packet.data().toHex().c_str()); rtt_packet.pack(); Packet test_packet(RNS::Destination(RNS::Type::NONE), rtt_packet.raw()); @@ -387,17 +477,13 @@ test_packet.unpack(); TRACEF("***** RTT test packet destination hash: %s", test_packet.destination_hash().toHex().c_str()); TRACEF("***** RTT test packet data size: %d", test_packet.data().size()); TRACEF("***** RTT test packet data: %s", test_packet.data().toHex().c_str()); -Bytes plaintext = decrypt(test_packet.data()); +Bytes plaintext = self.decrypt(test_packet.data()); TRACEF("***** RTT test packet plaintext: %s", plaintext.toHex().c_str()); rtt_packet.send(); - had_outbound(); - - if (_object->_callbacks._established != nullptr) { - VERBOSEF("Link %s is established", link_id().toHex().c_str()); - //p thread = threading.Thread(target=_object->_callbacks.link_established, args=(self,)) - //p thread.daemon = True - //p thread.start() - _object->_callbacks._established(*this); + self.had_outbound(); + + if (self._object->_callbacks._established != nullptr) { + self._object->_callbacks._established(self); } } else { @@ -456,9 +542,41 @@ const RNS::RequestReceipt Link::request(const Bytes& path, const Bytes& data /*= //p unpacked_request = [OS::time(), request_path_hash, data] //p packed_request = umsgpack.packb(unpacked_request) - MsgPack::Packer packer; - packer.to_array(OS::time(), request_path_hash, data); - Bytes packed_request(packer.data(), packer.size()); + // NOTE: data is pre-serialized msgpack. We must embed it as raw bytes + // (not BIN-wrapped) so Python peers see nested objects, not binary blobs. + // Using to_array() would call Bytes::to_msgpack() which packs as BIN type, + // causing Python's umsgpack.unpackb() to return bytes instead of the + // original structure. Build the packed request manually instead. + + // Pack timestamp as float64 + MsgPack::Packer ts_packer; + ts_packer.pack(OS::time()); + // Pack path_hash as BIN (correct: both Python and C++ expect bytes) + MsgPack::Packer ph_packer; + request_path_hash.to_msgpack(ph_packer); + + // Build [timestamp, path_hash, data] with data as raw embedded msgpack + size_t total = 1 + ts_packer.size() + ph_packer.size(); // 1 for fixarray header + if (data && data.size() > 0) { + total += data.size(); + } else { + total += 1; // nil byte + } + + Bytes packed_request; + uint8_t* p = packed_request.writable(total); + size_t pos = 0; + p[pos++] = 0x93; // fixarray of 3 + memcpy(p + pos, ts_packer.data(), ts_packer.size()); + pos += ts_packer.size(); + memcpy(p + pos, ph_packer.data(), ph_packer.size()); + pos += ph_packer.size(); + if (data && data.size() > 0) { + memcpy(p + pos, data.data(), data.size()); + pos += data.size(); + } else { + p[pos++] = 0xc0; // nil + } if (timeout == 0.0) { timeout = _object->_rtt * _object->_traffic_timeout_factor + Type::Resource::RESPONSE_MAX_GRACE_TIME * 1.125; @@ -983,18 +1101,16 @@ void Link::request_resource_concluded(const Resource& resource) { void Link::response_resource_concluded(const Resource& resource) { assert(_object); if (resource.status() == Type::Resource::COMPLETE) { - //p packed_response = resource.data.read() Bytes packed_response = resource.data(); - //p unpacked_response = umsgpack.unpackb(packed_response) - //p request_id = unpacked_response[0] - //p response_data = unpacked_response[1] - MsgPack::Unpacker unpacker; - unpacker.feed(packed_response.data(), packed_response.size()); - MsgPack::bin_t request_id; - MsgPack::bin_t response_data; - unpacker.from_array(request_id, response_data); - - handle_response(request_id, response_data, resource.total_size(), resource.size()); + // Parse [request_id, response_data] — response_data may be any msgpack type + Bytes request_id; + Bytes response_data; + size_t offset = parse_response_array(packed_response, request_id, response_data); + if (offset > 0) { + handle_response(request_id, response_data, resource.total_size(), resource.size()); + } else { + ERROR("response_resource_concluded: Failed to parse [request_id, response_data]"); + } } else { DEBUGF("Incoming response resource failed with status: %d", resource.status()); @@ -1049,6 +1165,7 @@ void Link::receive(const Packet& packet) { switch (packet.context()) { case Type::Packet::CONTEXT_NONE: { + DEBUGF("Link::receive CTX_NONE: data_sz=%zu first64=%s", packet.data().size(), packet.data().left(64).toHex().c_str()); const Bytes plaintext = decrypt(packet.data()); if (plaintext) { if (_object->_callbacks._packet) { @@ -1087,7 +1204,7 @@ void Link::receive(const Packet& packet) { { const Bytes plaintext = decrypt(packet.data()); if (plaintext) { - if (_object->_initiator && plaintext.size() == Type::Identity::KEYSIZE/8 + Type::Identity::SIGLENGTH/8) { + if (!_object->_initiator && plaintext.size() == Type::Identity::KEYSIZE/8 + Type::Identity::SIGLENGTH/8) { const Bytes public_key = plaintext.left(Type::Identity::KEYSIZE/8); const Bytes signed_data = _object->_link_id + public_key; const Bytes signature = plaintext.mid(Type::Identity::KEYSIZE/8, Type::Identity::SIGLENGTH/8); @@ -1143,16 +1260,17 @@ void Link::receive(const Packet& packet) { //p unpacked_response = umsgpack.unpackb(packed_response) //p request_id = unpacked_response[0] //p response_data = unpacked_response[1] - //p transfer_size = len(umsgpack.packb(response_data))-2 - MsgPack::Unpacker unpacker; - unpacker.feed(packed_response.data(), packed_response.size()); - MsgPack::bin_t request_id; - MsgPack::bin_t response_data; - unpacker.from_array(request_id, response_data); - MsgPack::Packer packer; - packer.serialize(response_data); - size_t transfer_size = packer.size() - 2; - handle_response(Bytes(request_id.data(), request_id.size()), Bytes(response_data.data(), response_data.size()), transfer_size, transfer_size); + //p transfer_size = len(umsgpack.packb(response_data))-2 + // NOTE: response_data may be any msgpack type (array, int, etc.) + // from Python peers, not just BIN. Extract request_id then take + // remaining raw bytes as response data for caller to parse. + Bytes request_id; + Bytes response_data; + size_t offset = parse_response_array(packed_response, request_id, response_data); + if (offset > 0) { + size_t transfer_size = response_data.size(); + handle_response(request_id, response_data, transfer_size, transfer_size); + } } } catch (std::exception& e) { @@ -1165,6 +1283,7 @@ void Link::receive(const Packet& packet) { if (!_object->_initiator) { rtt_packet(packet); } + break; } case Type::Packet::LINKCLOSE: { @@ -1265,8 +1384,10 @@ void Link::receive(const Packet& packet) { */ case Type::Packet::RESOURCE_ADV: { + DEBUGF("Link::receive RESOURCE_ADV: data_sz=%zu first64=%s", packet.data().size(), packet.data().left(64).toHex().c_str()); const Bytes plaintext = decrypt(packet.data()); if (plaintext) { + DEBUGF("Link::receive RESOURCE_ADV: decrypt OK pt_sz=%zu", plaintext.size()); // Store plaintext in packet for Resource::accept to use const_cast(packet).plaintext(plaintext); @@ -1478,7 +1599,7 @@ const Bytes Link::decrypt(const Bytes& ciphertext) { return _object->_token->decrypt(ciphertext); } catch (std::exception& e) { - ERRORF("Decryption failed on link %s. The contained exception was: %s", toString().c_str(), e.what()); + ERRORF("Decryption FAILED on link %s: %s (ct_sz=%zu ct_first32=%s)", toString().c_str(), e.what(), ciphertext.size(), ciphertext.left(32).toHex().c_str()); return {Bytes::NONE}; } } @@ -1593,7 +1714,7 @@ void Link::cancel_incoming_resource(const Resource& resource) { bool Link::ready_for_new_resource() { assert(_object); - return (_object->_outgoing_resources_count > 0); + return (_object->_outgoing_resources_count == 0); } SegmentAccumulator& Link::segment_accumulator() { @@ -1632,9 +1753,17 @@ void Link::handle_resource_concluded(const Resource& resource) { // Check if resource completed successfully if (resource_copy.status() != Type::Resource::COMPLETE) { - // Failed resource - clean up and notify application resource_concluded(resource_copy); DEBUGF("Link::handle_resource_concluded: Resource failed with status %d", resource_copy.status()); + // Check if this failed resource was a response to a pending request + if (resource_copy.request_id() && _object->_pending_requests_count > 0) { + for (size_t i = 0; i < _object->_pending_requests_count; i++) { + if (_object->_pending_requests[i].request_id() == resource_copy.request_id()) { + response_resource_concluded(resource_copy); + return; + } + } + } if (_object->_callbacks._resource_concluded) { _object->_callbacks._resource_concluded(resource_copy); } @@ -1664,6 +1793,37 @@ void Link::handle_resource_concluded(const Resource& resource) { DEBUG("Link::handle_resource_concluded: segment_completed returned false, falling through"); } + // Check if this resource is a response to a pending Link::request(). + // First try matching by resource's request_id field (from advertisement). + // If that's empty (Python servers often omit it), try parsing the resource + // data as [request_id, response_data] and matching the embedded request_id. + if (_object->_pending_requests_count > 0) { + Bytes match_id = resource_copy.request_id(); + + // If no request_id in resource metadata, try extracting from data + if (!match_id && resource_copy.data().size() > 3) { + Bytes extracted_id; + Bytes unused_data; + if (parse_response_array(resource_copy.data(), extracted_id, unused_data) > 0) { + match_id = extracted_id; + DEBUGF("Link::handle_resource_concluded: Extracted request_id from data: %s", + match_id.toHex().c_str()); + } + } + + if (match_id) { + for (size_t i = 0; i < _object->_pending_requests_count; i++) { + if (_object->_pending_requests[i].request_id() == match_id) { + DEBUGF("Link::handle_resource_concluded: Resource is response to pending request %s", + match_id.toHex().c_str()); + resource_concluded(resource_copy); + response_resource_concluded(resource_copy); + return; + } + } + } + } + // Single-segment resource or non-segmented - clean up and notify application resource_concluded(resource_copy); if (_object->_callbacks._resource_concluded) { @@ -1691,6 +1851,11 @@ const Destination& Link::destination() const { return _object->_destination; } +const Interface& Link::attached_interface() const { + assert(_object); + return _object->_attached_interface; +} + // CBA LINK /* const Destination& Link::link_destination() const { @@ -1946,6 +2111,16 @@ void Link::status(Type::Link::status status) { _object->_status = status; } +void Link::mtu(uint16_t mtu) { + assert(_object); + _object->_mtu = mtu; +} + +void Link::mode(RNS::Type::Link::link_mode mode) { + assert(_object); + _object->_mode = mode; +} + //RequestReceipt::RequestReceipt(const Link& link, const PacketReceipt& packet_receipt /*= {Type::NONE}*/, const Resource& resource /*= {Type::NONE}*/, RequestReceipt::Callbacks::response response_callback /*= nullptr*/, RequestReceipt::Callbacks::failed failed_callback /*= nullptr*/, RequestReceipt::Callbacks::progress progress_callback /*= nullptr*/, double timeout /*= 0.0*/, int request_size /*= 0*/) : RequestReceipt::RequestReceipt(const Link& link, const PacketReceipt& packet_receipt, const Resource& resource, RequestReceipt::Callbacks::response response_callback /*= nullptr*/, RequestReceipt::Callbacks::failed failed_callback /*= nullptr*/, RequestReceipt::Callbacks::progress progress_callback /*= nullptr*/, double timeout /*= 0.0*/, int request_size /*= 0*/) : diff --git a/src/Link.h b/src/Link.h index 98d8aac4..85ed9516 100644 --- a/src/Link.h +++ b/src/Link.h @@ -302,6 +302,8 @@ namespace RNS { void increment_tx(); void increment_txbytes(uint16_t bytes); void status(Type::Link::status status); + void mtu(uint16_t mtu); + void mode(RNS::Type::Link::link_mode mode); protected: std::shared_ptr _object; diff --git a/src/Log.cpp b/src/Log.cpp index bad13115..0eecd7b1 100644 --- a/src/Log.cpp +++ b/src/Log.cpp @@ -85,12 +85,14 @@ void RNS::doLog(LogLevel level, const char* msg) { return; } #ifdef ARDUINO - Serial.print(getTimeString()); - Serial.print(" ["); - Serial.print(getLevelName(level)); - Serial.print("] "); - Serial.println(msg); - Serial.flush(); + if (Serial) { + Serial.print(getTimeString()); + Serial.print(" ["); + Serial.print(getLevelName(level)); + Serial.print("] "); + Serial.println(msg); + Serial.flush(); + } #else printf("%s [%s] %s\n", getTimeString(), getLevelName(level), msg); fflush(stdout); @@ -102,7 +104,9 @@ void HEAD(const char* msg, LogLevel level) { return; } #ifdef ARDUINO - Serial.println(""); + if (Serial) { + Serial.println(""); + } #else printf("\n"); #endif diff --git a/src/Log.h b/src/Log.h index 9026996c..5caacbbb 100644 --- a/src/Log.h +++ b/src/Log.h @@ -29,7 +29,7 @@ #define DEBUGF(msg, ...) (RNS::debugf(msg, __VA_ARGS__)) #define TRACE(msg) (RNS::trace(msg)) #define TRACEF(msg, ...) (RNS::tracef(msg, __VA_ARGS__)) - #if defined(MEM_LOG) + #if defined(RNS_MEM_LOG) #define MEM(msg) (RNS::mem(msg)) #define MEMF(msg, ...) (RNS::memf(msg, __VA_ARGS__)) #else diff --git a/src/Packet.cpp b/src/Packet.cpp index a93aa688..e68eea24 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -38,7 +38,7 @@ size_t PacketReceipt::_pool_fallback_count = 0; // MEM-H2: Packet::Object allocation now uses pool to prevent heap fragmentation // Pool provides 24 slots, falls back to heap on exhaustion -Packet::Packet(const Destination& destination, const Interface& attached_interface, const Bytes& data, types packet_type /*= DATA*/, context_types context /*= CONTEXT_NONE*/, Type::Transport::types transport_type /*= Type::Transport::BROADCAST*/, header_types header_type /*= HEADER_1*/, const Bytes& transport_id /*= {Bytes::NONE}*/, bool create_receipt /*= true*/, context_flag context_flag /*= FLAG_UNSET*/) +Packet::Packet(const Destination& destination, const Interface& attached_interface, const Bytes& data, types packet_type /*= DATA*/, context_types context /*= CONTEXT_NONE*/, Type::Transport::types transport_type /*= Type::Transport::BROADCAST*/, header_types header_type /*= HEADER_1*/, const Bytes& transport_id /*= {Bytes::NONE}*/, bool create_receipt /*= true*/, Type::Packet::context_flag context_flag /*= FLAG_UNSET*/) { // Try pool first, fall back to heap on exhaustion Object* obj = objectPool().allocate(destination, attached_interface); @@ -104,7 +104,7 @@ Packet::Packet(const Destination& destination, const Interface& attached_interfa } // CBA LINK -Packet::Packet(const Link& link, const Bytes& data, Type::Packet::types packet_type /*= Type::Packet::DATA*/, Type::Packet::context_types context /*= Type::Packet::CONTEXT_NONE*/, context_flag context_flag /*= FLAG_UNSET*/) : +Packet::Packet(const Link& link, const Bytes& data, Type::Packet::types packet_type /*= Type::Packet::DATA*/, Type::Packet::context_types context /*= Type::Packet::CONTEXT_NONE*/, Type::Packet::context_flag context_flag /*= FLAG_UNSET*/) : //_object(new Object(link)) //Packet(link.destination(), data, packet_type, context, Type::Transport::BROADCAST, Type::Packet::HEADER_1, {Bytes::NONE}, true, context_flag) // CBA Must use a destination that targets the Link itself instead of the original destination used to create the link @@ -128,14 +128,14 @@ uint8_t Packet::get_packed_flags() { uint8_t packed_flags = 0; if (_object->_context == LRPROOF) { TRACE("***** Packing with LINK type"); - packed_flags = (_object->_header_type << 6) | (_object->_transport_type << 4) | (Type::Destination::LINK << 2) | _object->_packet_type; + packed_flags = (_object->_header_type << 6) | (_object->_context_flag << 5) | (_object->_transport_type << 4) | (Type::Destination::LINK << 2) | _object->_packet_type; } else { if (!_object->_destination) { throw std::logic_error("Packet destination is required"); } TRACEF("***** Packing with %d type", _object->_destination.type()); - packed_flags = (_object->_header_type << 6) | (_object->_transport_type << 4) | (_object->_destination.type() << 2) | _object->_packet_type; + packed_flags = (_object->_header_type << 6) | (_object->_context_flag << 5) | (_object->_transport_type << 4) | (_object->_destination.type() << 2) | _object->_packet_type; } return packed_flags; } @@ -143,7 +143,8 @@ TRACEF("***** Packing with %d type", _object->_destination.type()); void Packet::unpack_flags(uint8_t flags) { assert(_object); _object->_header_type = static_cast((flags & 0b01000000) >> 6); - _object->_transport_type = static_cast((flags & 0b00110000) >> 4); + _object->_context_flag = static_cast((flags & 0b00100000) >> 5); + _object->_transport_type = static_cast((flags & 0b00010000) >> 4); _object->_destination_type = static_cast((flags & 0b00001100) >> 2); _object->_packet_type = static_cast(flags & 0b00000011); } diff --git a/src/Packet.h b/src/Packet.h index 32aab55b..f2a818e7 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -283,6 +283,7 @@ namespace RNS { inline const Interface& attached_interface() const { assert(_object); return _object->_attached_interface; } inline const Interface& receiving_interface() const { assert(_object); return _object->_receiving_interface; } inline Type::Packet::header_types header_type() const { assert(_object); return _object->_header_type; } + inline Type::Packet::context_flag context_flag() const { assert(_object); return static_cast(_object->_context_flag); } inline Type::Transport::types transport_type() const { assert(_object); return _object->_transport_type; } inline Type::Destination::types destination_type() const { assert(_object); return _object->_destination_type; } inline Type::Packet::types packet_type() const { assert(_object); return _object->_packet_type; } diff --git a/src/Resource.cpp b/src/Resource.cpp index 28789b9d..f542f706 100644 --- a/src/Resource.cpp +++ b/src/Resource.cpp @@ -11,9 +11,6 @@ #include #include -#include -#include -#include #if defined(ESP_PLATFORM) || defined(ARDUINO) #include @@ -539,16 +536,16 @@ void Resource::cancel() { Packet cancel_packet(_object->_link, cancel_data, Type::Packet::DATA, Type::Packet::RESOURCE_ICL); cancel_packet.send(); - _object->_link.cancel_outgoing_resource(*this); } else { // Receiver cancelling - send RESOURCE_RCL (Receiver Cancel) Packet cancel_packet(_object->_link, cancel_data, Type::Packet::DATA, Type::Packet::RESOURCE_RCL); cancel_packet.send(); - _object->_link.cancel_incoming_resource(*this); } - // Fire concluded callback with FAILED status + // Fire concluded callback - this removes the resource from the link's + // tracking array via handle_resource_concluded, invalidating 'this'. + // Do NOT access _object after this point. if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } @@ -572,26 +569,35 @@ void Resource::check_timeout() { } _object->_watchdog_lock = true; + // Timeout handlers return true if the resource was terminated (concluded + // callback fired). When terminated, the concluded callback removes this + // resource from the link's tracking array via handle_resource_concluded(), + // which compacts the array and invalidates 'this'. We must NOT access + // _object after a terminal return. + bool terminated = false; + switch (_object->_status) { case Type::Resource::ADVERTISED: - timeout_advertised(); + terminated = timeout_advertised(); break; case Type::Resource::TRANSFERRING: - timeout_transferring(); + terminated = timeout_transferring(); break; case Type::Resource::AWAITING_PROOF: - timeout_awaiting_proof(); + terminated = timeout_awaiting_proof(); break; default: break; } - _object->_watchdog_lock = false; + if (!terminated) { + _object->_watchdog_lock = false; + } } -void Resource::timeout_advertised() { +bool Resource::timeout_advertised() { // Only sender can be in ADVERTISED state - if (!_object->_initiator) return; + if (!_object->_initiator) return false; double now = OS::time(); @@ -616,11 +622,14 @@ void Resource::timeout_advertised() { WARNING("Resource::timeout_advertised: Failing resource - internal RAM critically low (" + std::to_string(free_internal) + " bytes free)"); _object->_status = Type::Resource::FAILED; + // Reset watchdog before callback (callback may invalidate 'this') + _object->_watchdog_lock = false; if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - _object->_link.cancel_outgoing_resource(*this); - return; + // Do NOT access _object after callback - handle_resource_concluded + // already removed this resource from link tracking + return true; } #endif _object->_retries_left--; @@ -631,17 +640,19 @@ void Resource::timeout_advertised() { DEBUG("Resource::timeout_advertised: Advertisement timed out, max retries exceeded"); _object->_status = Type::Resource::FAILED; + // Reset watchdog before callback (callback may invalidate 'this') + _object->_watchdog_lock = false; if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - - // Unregister from link - _object->_link.cancel_outgoing_resource(*this); + // Do NOT access _object after callback + return true; } } + return false; } -void Resource::timeout_transferring() { +bool Resource::timeout_transferring() { double now = OS::time(); // Get RTT estimate @@ -663,11 +674,13 @@ void Resource::timeout_transferring() { DEBUG("Resource::timeout_transferring: Sender timeout waiting for requests"); _object->_status = Type::Resource::FAILED; + // Reset watchdog before callback (callback may invalidate 'this') + _object->_watchdog_lock = false; if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - - _object->_link.cancel_outgoing_resource(*this); + // Do NOT access _object after callback + return true; } } else { // Receiver: waiting for parts from sender @@ -703,11 +716,12 @@ void Resource::timeout_transferring() { WARNING("Resource::timeout_transferring: Failing resource - internal RAM critically low (" + std::to_string(free_internal) + " bytes free)"); _object->_status = Type::Resource::FAILED; + _object->_watchdog_lock = false; if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - _object->_link.cancel_incoming_resource(*this); - return; + // Do NOT access _object after callback + return true; } #endif _object->_retries_left--; @@ -728,19 +742,22 @@ void Resource::timeout_transferring() { DEBUG("Resource::timeout_transferring: Transfer timed out, max retries exceeded"); _object->_status = Type::Resource::FAILED; + // Reset watchdog before callback (callback may invalidate 'this') + _object->_watchdog_lock = false; if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - - _object->_link.cancel_incoming_resource(*this); + // Do NOT access _object after callback + return true; } } } + return false; } -void Resource::timeout_awaiting_proof() { +bool Resource::timeout_awaiting_proof() { // Only sender awaits proof - if (!_object->_initiator) return; + if (!_object->_initiator) return false; double now = OS::time(); @@ -759,12 +776,15 @@ void Resource::timeout_awaiting_proof() { DEBUG("Resource::timeout_awaiting_proof: Proof timed out"); _object->_status = Type::Resource::FAILED; + // Reset watchdog before callback (callback may invalidate 'this') + _object->_watchdog_lock = false; if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - - _object->_link.cancel_outgoing_resource(*this); + // Do NOT access _object after callback + return true; } + return false; } void Resource::prepare_next_segment() { @@ -1638,6 +1658,10 @@ void Resource::receive_part(const Packet& packet) { if (_object->_received_count >= _object->_total_parts) { DEBUG("Resource::receive_part: All parts received, assembling"); assemble(); + // IMPORTANT: assemble()'s concluded callback removes this resource from + // the link's _incoming_resources array, which invalidates 'this'. + // We must return immediately - do NOT access _object after this point. + return; } else if (_object->_outstanding_parts == 0) { // Dynamic window scaling: measure RTT and adjust window for fast links if (_object->_req_sent > 0) { @@ -1699,47 +1723,6 @@ void Resource::assemble() { TRACE("Resource::assemble: Starting assembly"); - // DEBUG: Save individual parts before assembly - { - std::string parts_dir = "/tmp/cpp_stage0_parts"; - // Create directory using system call - system("mkdir -p /tmp/cpp_stage0_parts"); - - for (size_t i = 0; i < _object->_parts.size(); i++) { - // Format part number with leading zeros (e.g., part_0000.bin) - std::ostringstream filename; - filename << parts_dir << "/part_" << std::setfill('0') << std::setw(4) << i << ".bin"; - - std::string filename_str = filename.str(); - std::ofstream f(filename_str, std::ios::binary); - if (f) { - const Bytes& part = _object->_parts[i]; - f.write(reinterpret_cast(part.data()), part.size()); - f.close(); - DEBUGF("Resource::assemble: Saved part %zu (%zu bytes) to %s", - i, part.size(), filename_str.c_str()); - } - } - - // Save parts metadata - std::ofstream meta("/tmp/cpp_parts_metadata.json", std::ios::out); - if (meta) { - meta << "{\n"; - meta << " \"total_parts\": " << _object->_parts.size() << ",\n"; - meta << " \"parts\": [\n"; - for (size_t i = 0; i < _object->_parts.size(); i++) { - meta << " {\"index\": " << i << ", \"size\": " << _object->_parts[i].size(); - meta << ", \"hash\": \"" << _object->_hashmap[i].toHex() << "\"}"; - if (i < _object->_parts.size() - 1) meta << ","; - meta << "\n"; - } - meta << " ]\n"; - meta << "}\n"; - meta.close(); - DEBUG("Resource::assemble: Saved parts metadata to /tmp/cpp_parts_metadata.json"); - } - } - // Concatenate all parts (Token-encrypted chunks) Bytes assembled_data; for (size_t i = 0; i < _object->_parts.size(); i++) { @@ -1748,16 +1731,6 @@ void Resource::assemble() { DEBUGF("Resource::assemble: Assembled %zu bytes from %zu parts", assembled_data.size(), _object->_parts.size()); - // DEBUG: Save encrypted data before decryption - { - std::ofstream f("/tmp/cpp_stage1_encrypted.bin", std::ios::binary); - if (f) { - f.write(reinterpret_cast(assembled_data.data()), assembled_data.size()); - f.close(); - DEBUGF("Resource::assemble: Saved %zu encrypted bytes to /tmp/cpp_stage1_encrypted.bin", assembled_data.size()); - } - } - // Decrypt if needed (Resource uses Token encryption via link.encrypt()) if (_object->_encrypted) { Bytes decrypted = _object->_link.decrypt(assembled_data); @@ -1769,16 +1742,6 @@ void Resource::assemble() { } assembled_data = decrypted; DEBUGF("Resource::assemble: Decrypted to %zu bytes", assembled_data.size()); - - // DEBUG: Save decrypted data (with random_hash) - { - std::ofstream f("/tmp/cpp_stage2_decrypted.bin", std::ios::binary); - if (f) { - f.write(reinterpret_cast(assembled_data.data()), assembled_data.size()); - f.close(); - DEBUGF("Resource::assemble: Saved %zu decrypted bytes to /tmp/cpp_stage2_decrypted.bin", assembled_data.size()); - } - } } // Strip off the random_hash prefix (4 bytes) @@ -1788,26 +1751,9 @@ void Resource::assemble() { _object->_assembly_lock = false; return; } - Bytes random_hash_prefix = assembled_data.left(Type::Resource::RANDOM_HASH_SIZE); - std::string random_hash_prefix_hex = random_hash_prefix.toHex(); - DEBUGF("Resource::assemble: random_hash prefix = %s", random_hash_prefix_hex.c_str()); assembled_data = assembled_data.mid(Type::Resource::RANDOM_HASH_SIZE); DEBUGF("Resource::assemble: After stripping random_hash: %zu bytes", assembled_data.size()); - // DEBUG: Save data after stripping random_hash (before decompression) - { - std::ofstream f("/tmp/cpp_stage3_stripped.bin", std::ios::binary); - if (f) { - f.write(reinterpret_cast(assembled_data.data()), assembled_data.size()); - f.close(); - DEBUGF("Resource::assemble: Saved %zu stripped bytes to /tmp/cpp_stage3_stripped.bin", assembled_data.size()); - std::string first_50_hex = assembled_data.left(50).toHex(); - std::string last_20_hex = assembled_data.right(20).toHex(); - DEBUGF("Resource::assemble: First 50 bytes: %s", first_50_hex.c_str()); - DEBUGF("Resource::assemble: Last 20 bytes: %s", last_20_hex.c_str()); - } - } - // Decompress if needed if (_object->_compressed) { Bytes decompressed = Cryptography::bz2_decompress(assembled_data); @@ -1819,20 +1765,6 @@ void Resource::assemble() { } assembled_data = decompressed; DEBUGF("Resource::assemble: Decompressed to %zu bytes", assembled_data.size()); - - // DEBUG: Save decompressed data - { - std::ofstream f("/tmp/cpp_stage4_decompressed.bin", std::ios::binary); - if (f) { - f.write(reinterpret_cast(assembled_data.data()), assembled_data.size()); - f.close(); - DEBUGF("Resource::assemble: Saved %zu decompressed bytes to /tmp/cpp_stage4_decompressed.bin", - assembled_data.size()); - std::string decompressed_50_hex = assembled_data.left(50).toHex(); - DEBUGF("Resource::assemble: Decompressed first 50 bytes: %s", - decompressed_50_hex.c_str()); - } - } } // Verify hash @@ -1855,35 +1787,6 @@ void Resource::assemble() { DEBUGF("Resource::assemble: Assembly complete, data_size=%zu, expected_total_size=%zu", _object->_data.size(), _object->_total_size); - // DEBUG: Save final verified data - { - std::ofstream f("/tmp/cpp_stage5_final.bin", std::ios::binary); - if (f) { - f.write(reinterpret_cast(assembled_data.data()), assembled_data.size()); - f.close(); - DEBUGF("Resource::assemble: Saved %zu final verified bytes to /tmp/cpp_stage5_final.bin", - assembled_data.size()); - } - - // Save comprehensive metadata - std::ofstream meta("/tmp/cpp_final_metadata.json", std::ios::out); - if (meta) { - meta << "{\n"; - meta << " \"resource_hash\": \"" << _object->_hash.toHex() << "\",\n"; - meta << " \"random_hash\": \"" << _object->_random_hash.toHex() << "\",\n"; - meta << " \"total_size\": " << _object->_total_size << ",\n"; - meta << " \"transfer_size\": " << _object->_size << ",\n"; - meta << " \"total_parts\": " << _object->_total_parts << ",\n"; - meta << " \"compressed\": " << (_object->_compressed ? "true" : "false") << ",\n"; - meta << " \"encrypted\": " << (_object->_encrypted ? "true" : "false") << ",\n"; - meta << " \"final_data_size\": " << _object->_data.size() << ",\n"; - meta << " \"hash_verification\": \"PASSED\"\n"; - meta << "}\n"; - meta.close(); - DEBUG("Resource::assemble: Saved final metadata to /tmp/cpp_final_metadata.json"); - } - } - // Validate data size matches advertised total_size if (_object->_data.size() != _object->_total_size) { ERRORF("Resource::assemble: SIZE MISMATCH! received %zu bytes, expected %zu bytes", @@ -1893,12 +1796,15 @@ void Resource::assemble() { // Send proof to sender prove(); - // Call concluded callback + // Release assembly lock BEFORE concluded callback, because the callback + // may remove this resource from the link's tracking (dropping our shared_ptr + // ref count), invalidating _object. Assembly is complete at this point anyway. + _object->_assembly_lock = false; + + // Call concluded callback (may destroy this resource's _object via shared_ptr) if (_object->_callbacks._concluded != nullptr) { _object->_callbacks._concluded(*this); } - - _object->_assembly_lock = false; } // Send proof that resource was received diff --git a/src/Resource.h b/src/Resource.h index 21ab31c2..c1613311 100644 --- a/src/Resource.h +++ b/src/Resource.h @@ -89,9 +89,9 @@ namespace RNS { void check_timeout(); private: - void timeout_advertised(); - void timeout_transferring(); - void timeout_awaiting_proof(); + bool timeout_advertised(); + bool timeout_transferring(); + bool timeout_awaiting_proof(); public: // Multi-segment sending diff --git a/src/Reticulum.cpp b/src/Reticulum.cpp index c7464a97..548a5ca5 100644 --- a/src/Reticulum.cpp +++ b/src/Reticulum.cpp @@ -80,6 +80,9 @@ Reticulum::Reticulum() : _object(new Object()) { //RNG.addNoiseSource(noise); #endif + // Initialize Transport pools in PSRAM before any interface registration + Transport::init_pools(); + //z RNS.vendor.platformutils.platform_checks() /*p TODO diff --git a/src/Transport.cpp b/src/Transport.cpp index b4ae78df..6bfeaf73 100644 --- a/src/Transport.cpp +++ b/src/Transport.cpp @@ -13,6 +13,11 @@ #include #include #include +#include // For placement new + +#ifdef ARDUINO +#include +#endif using namespace RNS; using namespace RNS::Type::Transport; @@ -26,71 +31,71 @@ using namespace RNS::Utilities; #elif defined(INTERFACES_MAP) /*static*/ std::map Transport::_interfaces; #elif defined(INTERFACES_POOL) -/*static*/ Transport::InterfaceSlot Transport::_interfaces_pool[Transport::INTERFACES_POOL_SIZE]; +/*static*/ Transport::InterfaceSlot* Transport::_interfaces_pool = nullptr; #endif #if defined(DESTINATIONS_SET) /*static*/ std::set Transport::_destinations; #elif defined(DESTINATIONS_MAP) /*static*/ std::map Transport::_destinations; #elif defined(DESTINATIONS_POOL) -/*static*/ Transport::DestinationSlot Transport::_destinations_pool[Transport::DESTINATIONS_POOL_SIZE]; +/*static*/ Transport::DestinationSlot* Transport::_destinations_pool = nullptr; #endif ///*static*/ std::set Transport::_pending_links; // Replaced by fixed array ///*static*/ std::set Transport::_active_links; // Replaced by fixed array -/*static*/ Link Transport::_pending_links_pool[Transport::PENDING_LINKS_SIZE]; +/*static*/ Link* Transport::_pending_links_pool = nullptr; /*static*/ size_t Transport::_pending_links_count = 0; -/*static*/ Link Transport::_active_links_pool[Transport::ACTIVE_LINKS_SIZE]; +/*static*/ Link* Transport::_active_links_pool = nullptr; /*static*/ size_t Transport::_active_links_count = 0; ///*static*/ std::set Transport::_packet_hashlist; // Replaced by circular buffer -/*static*/ Bytes Transport::_packet_hashlist_buffer[Transport::PACKET_HASHLIST_SIZE]; +/*static*/ Bytes* Transport::_packet_hashlist_buffer = nullptr; /*static*/ size_t Transport::_packet_hashlist_head = 0; /*static*/ size_t Transport::_packet_hashlist_count = 0; ///*static*/ std::set Transport::_discovery_pr_tags; // Replaced by circular buffer -/*static*/ Bytes Transport::_discovery_pr_tags_buffer[Transport::DISCOVERY_PR_TAGS_SIZE]; +/*static*/ Bytes* Transport::_discovery_pr_tags_buffer = nullptr; /*static*/ size_t Transport::_discovery_pr_tags_head = 0; /*static*/ size_t Transport::_discovery_pr_tags_count = 0; ///*static*/ std::list Transport::_receipts; // Replaced by fixed array -/*static*/ PacketReceipt Transport::_receipts_pool[Transport::RECEIPTS_SIZE]; +/*static*/ PacketReceipt* Transport::_receipts_pool = nullptr; /*static*/ size_t Transport::_receipts_count = 0; // Announce table pool (replaces std::map) -/*static*/ Transport::AnnounceTableSlot Transport::_announce_table_pool[Transport::ANNOUNCE_TABLE_SIZE]; +/*static*/ Transport::AnnounceTableSlot* Transport::_announce_table_pool = nullptr; // Destination table pool (replaces std::map) -/*static*/ Transport::DestinationTableSlot Transport::_destination_table_pool[Transport::DESTINATION_TABLE_SIZE]; +/*static*/ Transport::DestinationTableSlot* Transport::_destination_table_pool = nullptr; ///*static*/ std::map Transport::_reverse_table; // Replaced by fixed pool -/*static*/ Transport::ReverseTableSlot Transport::_reverse_table_pool[Transport::REVERSE_TABLE_SIZE]; +/*static*/ Transport::ReverseTableSlot* Transport::_reverse_table_pool = nullptr; ///*static*/ std::map Transport::_link_table; // Replaced by fixed pool -/*static*/ Transport::LinkTableSlot Transport::_link_table_pool[Transport::LINK_TABLE_SIZE]; +/*static*/ Transport::LinkTableSlot* Transport::_link_table_pool = nullptr; ///*static*/ std::map Transport::_held_announces; // Replaced by pool -/*static*/ Transport::HeldAnnounceSlot Transport::_held_announces_pool[Transport::HELD_ANNOUNCES_SIZE]; +/*static*/ Transport::HeldAnnounceSlot* Transport::_held_announces_pool = nullptr; ///*static*/ std::set Transport::_announce_handlers; // Replaced by fixed array -/*static*/ HAnnounceHandler Transport::_announce_handlers_pool[Transport::ANNOUNCE_HANDLERS_SIZE]; +/*static*/ HAnnounceHandler* Transport::_announce_handlers_pool = nullptr; /*static*/ size_t Transport::_announce_handlers_count = 0; ///*static*/ std::map Transport::_tunnels; // Replaced by fixed pool -/*static*/ Transport::TunnelSlot Transport::_tunnels_pool[Transport::TUNNELS_SIZE]; +/*static*/ Transport::TunnelSlot* Transport::_tunnels_pool = nullptr; ///*static*/ std::map Transport::_announce_rate_table; // Replaced by fixed pool -/*static*/ Transport::RateTableSlot Transport::_announce_rate_table_pool[Transport::ANNOUNCE_RATE_TABLE_SIZE]; +/*static*/ Transport::RateTableSlot* Transport::_announce_rate_table_pool = nullptr; ///*static*/ std::map Transport::_path_requests; // Replaced by fixed pool -/*static*/ Transport::PathRequestSlot Transport::_path_requests_pool[Transport::PATH_REQUESTS_SIZE]; +/*static*/ Transport::PathRequestSlot* Transport::_path_requests_pool = nullptr; ///*static*/ std::map Transport::_discovery_path_requests; // Replaced by pool -/*static*/ Transport::DiscoveryPathRequestSlot Transport::_discovery_path_requests_pool[Transport::DISCOVERY_PATH_REQUESTS_SIZE]; +/*static*/ Transport::DiscoveryPathRequestSlot* Transport::_discovery_path_requests_pool = nullptr; ///*static*/ std::set Transport::_discovery_pr_tags; // Replaced by circular buffer ///*static*/ std::set Transport::_control_destinations; // Replaced by fixed array -/*static*/ Destination Transport::_control_destinations_pool[Transport::CONTROL_DESTINATIONS_SIZE]; +/*static*/ Destination* Transport::_control_destinations_pool = nullptr; /*static*/ size_t Transport::_control_destinations_count = 0; ///*static*/ std::set Transport::_control_hashes; // Replaced by fixed array -/*static*/ Bytes Transport::_control_hashes_pool[Transport::CONTROL_HASHES_SIZE]; +/*static*/ Bytes* Transport::_control_hashes_pool = nullptr; /*static*/ size_t Transport::_control_hashes_count = 0; ///*static*/ std::set Transport::_local_client_interfaces; ///*static*/ std::set, std::less> Transport::_local_client_interfaces; // Replaced by fixed array -/*static*/ const Interface* Transport::_local_client_interfaces_pool[Transport::LOCAL_CLIENT_INTERFACES_SIZE]; +/*static*/ const Interface** Transport::_local_client_interfaces_pool = nullptr; /*static*/ size_t Transport::_local_client_interfaces_count = 0; ///*static*/ std::map Transport::_pending_local_path_requests; // Replaced by pool -/*static*/ Transport::PendingLocalPathRequestSlot Transport::_pending_local_path_requests_pool[Transport::PENDING_LOCAL_PATH_REQUESTS_SIZE]; +/*static*/ Transport::PendingLocalPathRequestSlot* Transport::_pending_local_path_requests_pool = nullptr; // CBA - _packet_table not currently used, commented out to reduce STL container usage ///*static*/ std::map Transport::_packet_table; @@ -154,6 +159,89 @@ using namespace RNS::Utilities; /*static*/ size_t Transport::_last_memory = 0; /*static*/ size_t Transport::_last_flash = 0; +// Allocate all Transport pools in PSRAM (frees ~15-25KB internal RAM) +// Follows same pattern as Identity::init_known_destinations_pool() +/*static*/ bool Transport::init_pools() { + if (_announce_table_pool != nullptr) { + return true; // Already initialized + } + + size_t total_bytes = 0; + +#ifdef ARDUINO + // Helper: allocate in PSRAM with fallback to internal RAM, then placement-new each element + #define ALLOC_POOL(pool, type, count, name) do { \ + size_t sz = (count) * sizeof(type); \ + pool = (type*)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); \ + if (!pool) { \ + pool = (type*)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); \ + if (pool) { WARNING("Transport: " name " allocated in internal RAM (PSRAM unavailable)"); } \ + } \ + if (!pool) { \ + ERROR("Transport: Failed to allocate " name " (" + std::to_string(sz) + " bytes)"); \ + return false; \ + } \ + for (size_t _i = 0; _i < (count); _i++) { new (&pool[_i]) type(); } \ + total_bytes += sz; \ + } while(0) +#else + // Non-Arduino: use regular new[] + #define ALLOC_POOL(pool, type, count, name) do { \ + pool = new type[count]; \ + if (!pool) { return false; } \ + total_bytes += (count) * sizeof(type); \ + } while(0) +#endif + + ALLOC_POOL(_announce_table_pool, AnnounceTableSlot, ANNOUNCE_TABLE_SIZE, "announce_table"); + ALLOC_POOL(_destination_table_pool, DestinationTableSlot, DESTINATION_TABLE_SIZE, "destination_table"); + ALLOC_POOL(_reverse_table_pool, ReverseTableSlot, REVERSE_TABLE_SIZE, "reverse_table"); + ALLOC_POOL(_link_table_pool, LinkTableSlot, LINK_TABLE_SIZE, "link_table"); + ALLOC_POOL(_held_announces_pool, HeldAnnounceSlot, HELD_ANNOUNCES_SIZE, "held_announces"); + ALLOC_POOL(_tunnels_pool, TunnelSlot, TUNNELS_SIZE, "tunnels"); + ALLOC_POOL(_announce_rate_table_pool, RateTableSlot, ANNOUNCE_RATE_TABLE_SIZE, "announce_rate_table"); + ALLOC_POOL(_path_requests_pool, PathRequestSlot, PATH_REQUESTS_SIZE, "path_requests"); + ALLOC_POOL(_receipts_pool, PacketReceipt, RECEIPTS_SIZE, "receipts"); + ALLOC_POOL(_packet_hashlist_buffer, Bytes, PACKET_HASHLIST_SIZE, "packet_hashlist"); + ALLOC_POOL(_discovery_pr_tags_buffer, Bytes, DISCOVERY_PR_TAGS_SIZE, "discovery_pr_tags"); + ALLOC_POOL(_pending_links_pool, Link, PENDING_LINKS_SIZE, "pending_links"); + ALLOC_POOL(_active_links_pool, Link, ACTIVE_LINKS_SIZE, "active_links"); + ALLOC_POOL(_control_hashes_pool, Bytes, CONTROL_HASHES_SIZE, "control_hashes"); + ALLOC_POOL(_control_destinations_pool, Destination, CONTROL_DESTINATIONS_SIZE, "control_destinations"); + ALLOC_POOL(_announce_handlers_pool, HAnnounceHandler, ANNOUNCE_HANDLERS_SIZE, "announce_handlers"); + + // local_client_interfaces is an array of pointers, not objects + { + size_t sz = LOCAL_CLIENT_INTERFACES_SIZE * sizeof(const Interface*); +#ifdef ARDUINO + _local_client_interfaces_pool = (const Interface**)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!_local_client_interfaces_pool) { + _local_client_interfaces_pool = (const Interface**)heap_caps_aligned_alloc(8, sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + } +#else + _local_client_interfaces_pool = new const Interface*[LOCAL_CLIENT_INTERFACES_SIZE]; +#endif + if (!_local_client_interfaces_pool) { + ERROR("Transport: Failed to allocate local_client_interfaces"); + return false; + } + for (size_t i = 0; i < LOCAL_CLIENT_INTERFACES_SIZE; i++) { + _local_client_interfaces_pool[i] = nullptr; + } + total_bytes += sz; + } + + ALLOC_POOL(_interfaces_pool, InterfaceSlot, INTERFACES_POOL_SIZE, "interfaces"); + ALLOC_POOL(_destinations_pool, DestinationSlot, DESTINATIONS_POOL_SIZE, "destinations"); + ALLOC_POOL(_discovery_path_requests_pool, DiscoveryPathRequestSlot, DISCOVERY_PATH_REQUESTS_SIZE, "discovery_path_requests"); + ALLOC_POOL(_pending_local_path_requests_pool, PendingLocalPathRequestSlot, PENDING_LOCAL_PATH_REQUESTS_SIZE, "pending_local_path_requests"); + + #undef ALLOC_POOL + + INFO("Transport pools initialized (" + std::to_string(total_bytes) + " bytes total)"); + return true; +} + /*static*/ bool Transport::packet_hashlist_contains(const Bytes& hash) { for (size_t i = 0; i < _packet_hashlist_count; i++) { size_t idx = (_packet_hashlist_head + PACKET_HASHLIST_SIZE - _packet_hashlist_count + i) % PACKET_HASHLIST_SIZE; @@ -1114,7 +1202,12 @@ using namespace RNS::Utilities; AnnounceEntry& announce_entry = slot.entry; //TRACE("[0] announce entry data size: " + std::to_string(announce_entry._packet.data().size())); //p announce_entry = Transport.announce_table[destination_hash] - if (announce_entry._retries > Type::Transport::PATHFINDER_R) { + if (announce_entry._retries > 0 && announce_entry._retries >= Type::Transport::LOCAL_REBROADCASTS_MAX) { + TRACE("Completed announce processing for " + destination_hash.toHex() + ", local rebroadcast limit reached"); + slot.clear(); + break; + } + else if (announce_entry._retries > Type::Transport::PATHFINDER_R) { TRACE("Completed announce processing for " + destination_hash.toHex() + ", retry limit reached"); // CBA OK to modify collection here since we're immediately exiting iteration slot.clear(); @@ -1154,7 +1247,9 @@ using namespace RNS::Utilities; announce_context, Type::Transport::TRANSPORT, Type::Packet::HEADER_2, - Transport::_identity.hash() + Transport::_identity.hash(), + true, + announce_entry._packet.context_flag() ); new_packet.hops(announce_entry._hops); @@ -1678,10 +1773,11 @@ using namespace RNS::Utilities; TRACE("Transport::outbound: Pscket destination is link-closed, not transmitting"); should_transmit = false; } - // CBA Bug? Destination has no member attached_interface - //z if (interface != packet.destination().attached_interface()) { - //z should_transmit = false; - //z } + // Route link packets only through the link's attached interface + if (packet.destination_link().attached_interface() && interface != packet.destination_link().attached_interface()) { + TRACE("Transport::outbound: Link packet not for this interface, skipping"); + should_transmit = false; + } } if (packet.attached_interface() && interface != packet.attached_interface()) { @@ -2028,7 +2124,7 @@ using namespace RNS::Utilities; } /*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) { - TRACE("Transport::inbound()"); + TRACEF("Transport::inbound: received %zu bytes", raw.size()); ++_packets_received; // CBA if (_callbacks._receive_packet) { @@ -2809,7 +2905,9 @@ using namespace RNS::Utilities; announce_context, Type::Transport::TRANSPORT, Type::Packet::HEADER_2, - _identity.hash() + _identity.hash(), + true, + packet.context_flag() ); new_announce.hops(packet.hops()); @@ -2829,7 +2927,9 @@ using namespace RNS::Utilities; announce_context, Type::Transport::TRANSPORT, Type::Packet::HEADER_2, - _identity.hash() + _identity.hash(), + true, + packet.context_flag() ); new_announce.hops(packet.hops()); @@ -2863,7 +2963,9 @@ using namespace RNS::Utilities; Type::Packet::PATH_RESPONSE, Type::Transport::TRANSPORT, Type::Packet::HEADER_2, - _identity.hash() + _identity.hash(), + true, + packet.context_flag() ); new_announce.hops(packet.hops()); diff --git a/src/Transport.h b/src/Transport.h index 64379078..9729c9de 100644 --- a/src/Transport.h +++ b/src/Transport.h @@ -391,62 +391,62 @@ namespace RNS { // Fixed-size pool structures for zero heap fragmentation // Overflow behavior: find_empty_*_slot() returns nullptr when pool full // Callers must check for nullptr and handle gracefully (drop/log/cull) - static constexpr size_t ANNOUNCE_TABLE_SIZE = 8; // Reduced for testing + static constexpr size_t ANNOUNCE_TABLE_SIZE = 32; struct AnnounceTableSlot { bool in_use = false; Bytes destination_hash; AnnounceEntry entry; void clear() { in_use = false; destination_hash.clear(); entry = AnnounceEntry(); } }; - static AnnounceTableSlot _announce_table_pool[ANNOUNCE_TABLE_SIZE]; + static AnnounceTableSlot* _announce_table_pool; static AnnounceTableSlot* find_announce_table_slot(const Bytes& hash); static AnnounceTableSlot* find_empty_announce_table_slot(); static size_t announce_table_count(); - static constexpr size_t DESTINATION_TABLE_SIZE = 16; // Reduced for testing + static constexpr size_t DESTINATION_TABLE_SIZE = 64; struct DestinationTableSlot { bool in_use = false; Bytes destination_hash; DestinationEntry entry; void clear() { in_use = false; destination_hash.clear(); entry = DestinationEntry(); } }; - static DestinationTableSlot _destination_table_pool[DESTINATION_TABLE_SIZE]; + static DestinationTableSlot* _destination_table_pool; static DestinationTableSlot* find_destination_table_slot(const Bytes& hash); static DestinationTableSlot* find_empty_destination_table_slot(); static size_t destination_table_count(); - static constexpr size_t REVERSE_TABLE_SIZE = 8; // Reduced for testing + static constexpr size_t REVERSE_TABLE_SIZE = 16; struct ReverseTableSlot { bool in_use = false; Bytes packet_hash; ReverseEntry entry; void clear() { in_use = false; packet_hash.clear(); entry = ReverseEntry(); } }; - static ReverseTableSlot _reverse_table_pool[REVERSE_TABLE_SIZE]; + static ReverseTableSlot* _reverse_table_pool; static ReverseTableSlot* find_reverse_table_slot(const Bytes& hash); static ReverseTableSlot* find_empty_reverse_table_slot(); static size_t reverse_table_count(); - static constexpr size_t LINK_TABLE_SIZE = 8; // Reduced for testing + static constexpr size_t LINK_TABLE_SIZE = 16; struct LinkTableSlot { bool in_use = false; Bytes link_id; LinkEntry entry; void clear() { in_use = false; link_id.clear(); entry = LinkEntry(); } }; - static LinkTableSlot _link_table_pool[LINK_TABLE_SIZE]; + static LinkTableSlot* _link_table_pool; static LinkTableSlot* find_link_table_slot(const Bytes& id); static LinkTableSlot* find_empty_link_table_slot(); static size_t link_table_count(); - static constexpr size_t HELD_ANNOUNCES_SIZE = 8; // Reduced for testing + static constexpr size_t HELD_ANNOUNCES_SIZE = 16; struct HeldAnnounceSlot { bool in_use = false; Bytes destination_hash; AnnounceEntry entry; void clear() { in_use = false; destination_hash.clear(); entry = AnnounceEntry(); } }; - static HeldAnnounceSlot _held_announces_pool[HELD_ANNOUNCES_SIZE]; + static HeldAnnounceSlot* _held_announces_pool; static HeldAnnounceSlot* find_held_announce_slot(const Bytes& hash); static HeldAnnounceSlot* find_empty_held_announce_slot(); static size_t held_announces_count(); @@ -458,46 +458,46 @@ namespace RNS { TunnelEntry entry; void clear() { in_use = false; tunnel_id.clear(); entry.clear(); } }; - static TunnelSlot _tunnels_pool[TUNNELS_SIZE]; + static TunnelSlot* _tunnels_pool; static TunnelSlot* find_tunnel_slot(const Bytes& id); static TunnelSlot* find_empty_tunnel_slot(); static size_t tunnels_count(); - static constexpr size_t ANNOUNCE_RATE_TABLE_SIZE = 8; // Reduced for testing + static constexpr size_t ANNOUNCE_RATE_TABLE_SIZE = 16; struct RateTableSlot { bool in_use = false; Bytes destination_hash; RateEntry entry; void clear() { in_use = false; destination_hash.clear(); entry = RateEntry(); } }; - static RateTableSlot _announce_rate_table_pool[ANNOUNCE_RATE_TABLE_SIZE]; + static RateTableSlot* _announce_rate_table_pool; static RateTableSlot* find_rate_table_slot(const Bytes& hash); static RateTableSlot* find_empty_rate_table_slot(); static size_t announce_rate_table_count(); - static constexpr size_t PATH_REQUESTS_SIZE = 8; // Reduced for testing + static constexpr size_t PATH_REQUESTS_SIZE = 32; struct PathRequestSlot { bool in_use = false; Bytes destination_hash; double timestamp = 0; void clear() { in_use = false; destination_hash.clear(); timestamp = 0; } }; - static PathRequestSlot _path_requests_pool[PATH_REQUESTS_SIZE]; + static PathRequestSlot* _path_requests_pool; static PathRequestSlot* find_path_request_slot(const Bytes& hash); static PathRequestSlot* find_empty_path_request_slot(); static size_t path_requests_count(); // Receipts fixed array - static constexpr size_t RECEIPTS_SIZE = 8; // Reduced for testing - static PacketReceipt _receipts_pool[RECEIPTS_SIZE]; + static constexpr size_t RECEIPTS_SIZE = 16; + static PacketReceipt* _receipts_pool; static size_t _receipts_count; static bool receipts_add(const PacketReceipt& receipt); static bool receipts_remove(const PacketReceipt& receipt); static inline size_t receipts_count() { return _receipts_count; } // Packet hashlist circular buffer (replaces std::set) - static constexpr size_t PACKET_HASHLIST_SIZE = 64; // Reduced for testing - static Bytes _packet_hashlist_buffer[PACKET_HASHLIST_SIZE]; + static constexpr size_t PACKET_HASHLIST_SIZE = 128; + static Bytes* _packet_hashlist_buffer; static size_t _packet_hashlist_head; static size_t _packet_hashlist_count; static bool packet_hashlist_contains(const Bytes& hash); @@ -507,15 +507,15 @@ namespace RNS { // Discovery PR tags circular buffer (replaces std::set) static constexpr size_t DISCOVERY_PR_TAGS_SIZE = 32; - static Bytes _discovery_pr_tags_buffer[DISCOVERY_PR_TAGS_SIZE]; + static Bytes* _discovery_pr_tags_buffer; static size_t _discovery_pr_tags_head; static size_t _discovery_pr_tags_count; static bool discovery_pr_tags_contains(const Bytes& tag); static void discovery_pr_tags_add(const Bytes& tag); // Pending links fixed array (replaces std::set) - static constexpr size_t PENDING_LINKS_SIZE = 4; // Reduced for testing - static Link _pending_links_pool[PENDING_LINKS_SIZE]; + static constexpr size_t PENDING_LINKS_SIZE = 8; + static Link* _pending_links_pool; static size_t _pending_links_count; static bool pending_links_contains(const Link& link); static bool pending_links_add(const Link& link); @@ -524,7 +524,7 @@ namespace RNS { // Active links fixed array (replaces std::set) static constexpr size_t ACTIVE_LINKS_SIZE = 4; // Reduced for testing - static Link _active_links_pool[ACTIVE_LINKS_SIZE]; + static Link* _active_links_pool; static size_t _active_links_count; static bool active_links_contains(const Link& link); static bool active_links_add(const Link& link); @@ -533,7 +533,7 @@ namespace RNS { // Control hashes fixed array (replaces std::set) static constexpr size_t CONTROL_HASHES_SIZE = 8; - static Bytes _control_hashes_pool[CONTROL_HASHES_SIZE]; + static Bytes* _control_hashes_pool; static size_t _control_hashes_count; static bool control_hashes_contains(const Bytes& hash); static bool control_hashes_add(const Bytes& hash); @@ -541,14 +541,14 @@ namespace RNS { // Control destinations fixed array (replaces std::set) static constexpr size_t CONTROL_DESTINATIONS_SIZE = 8; - static Destination _control_destinations_pool[CONTROL_DESTINATIONS_SIZE]; + static Destination* _control_destinations_pool; static size_t _control_destinations_count; static bool control_destinations_add(const Destination& dest); static size_t control_destinations_size(); // Announce handlers fixed array (replaces std::set) static constexpr size_t ANNOUNCE_HANDLERS_SIZE = 8; - static HAnnounceHandler _announce_handlers_pool[ANNOUNCE_HANDLERS_SIZE]; + static HAnnounceHandler* _announce_handlers_pool; static size_t _announce_handlers_count; static bool announce_handlers_add(HAnnounceHandler handler); static bool announce_handlers_remove(HAnnounceHandler handler); @@ -556,7 +556,7 @@ namespace RNS { // Local client interfaces fixed array (replaces std::set>) static constexpr size_t LOCAL_CLIENT_INTERFACES_SIZE = 8; - static const Interface* _local_client_interfaces_pool[LOCAL_CLIENT_INTERFACES_SIZE]; + static const Interface** _local_client_interfaces_pool; static size_t _local_client_interfaces_count; static bool local_client_interfaces_contains(const Interface& iface); static bool local_client_interfaces_add(const Interface& iface); @@ -571,7 +571,7 @@ namespace RNS { void clear() { in_use = false; hash.clear(); interface_ptr = nullptr; } }; static constexpr size_t INTERFACES_POOL_SIZE = 8; - static InterfaceSlot _interfaces_pool[INTERFACES_POOL_SIZE]; + static InterfaceSlot* _interfaces_pool; static InterfaceSlot* find_interface_slot(const Bytes& hash); static InterfaceSlot* find_empty_interface_slot(); static size_t interfaces_count(); @@ -585,7 +585,7 @@ namespace RNS { void clear() { in_use = false; hash.clear(); destination = Destination(); } }; static constexpr size_t DESTINATIONS_POOL_SIZE = 32; - static DestinationSlot _destinations_pool[DESTINATIONS_POOL_SIZE]; + static DestinationSlot* _destinations_pool; static DestinationSlot* find_destination_slot(const Bytes& hash); static DestinationSlot* find_empty_destination_slot(); static size_t destinations_count(); @@ -600,7 +600,7 @@ namespace RNS { void clear() { in_use = false; destination_hash.clear(); timeout = 0; requesting_interface = Interface(Type::NONE); } }; static constexpr size_t DISCOVERY_PATH_REQUESTS_SIZE = 32; - static DiscoveryPathRequestSlot _discovery_path_requests_pool[DISCOVERY_PATH_REQUESTS_SIZE]; + static DiscoveryPathRequestSlot* _discovery_path_requests_pool; static DiscoveryPathRequestSlot* find_discovery_path_request_slot(const Bytes& hash); static DiscoveryPathRequestSlot* find_empty_discovery_path_request_slot(); static size_t discovery_path_requests_count(); @@ -613,12 +613,13 @@ namespace RNS { void clear() { in_use = false; destination_hash.clear(); attached_interface = Interface(Type::NONE); } }; static constexpr size_t PENDING_LOCAL_PATH_REQUESTS_SIZE = 32; - static PendingLocalPathRequestSlot _pending_local_path_requests_pool[PENDING_LOCAL_PATH_REQUESTS_SIZE]; + static PendingLocalPathRequestSlot* _pending_local_path_requests_pool; static PendingLocalPathRequestSlot* find_pending_local_path_request_slot(const Bytes& hash); static PendingLocalPathRequestSlot* find_empty_pending_local_path_request_slot(); static size_t pending_local_path_requests_count(); public: + static bool init_pools(); // Call early in startup, allocates pools in PSRAM static void start(const Reticulum& reticulum_instance); static void loop(); static void jobs(); diff --git a/src/UI/LXMF/ConversationListScreen.cpp b/src/UI/LXMF/ConversationListScreen.cpp index 2f34bd05..43fa9d40 100644 --- a/src/UI/LXMF/ConversationListScreen.cpp +++ b/src/UI/LXMF/ConversationListScreen.cpp @@ -196,8 +196,8 @@ void ConversationListScreen::refresh() { _conversation_containers.clear(); _peer_hash_pool.clear(); - // Load conversations from store - std::vector peer_hashes = _message_store->get_conversations(); + // Load conversations from store + std::vector peer_hashes = _message_store->get_conversations(); // Reserve capacity to avoid reallocations during population _peer_hash_pool.reserve(peer_hashes.size()); @@ -210,20 +210,13 @@ void ConversationListScreen::refresh() { INFO(log_buf); } - for (const auto& peer_hash : peer_hashes) { - std::vector messages = _message_store->get_messages_for_conversation(peer_hash); + for (const auto& peer_hash : peer_hashes) { + ::LXMF::MessageStore::ConversationInfo info = _message_store->get_conversation_info(peer_hash); + Bytes last_msg_hash = info.last_message_hash_bytes(); - if (messages.empty()) { - continue; - } - - // Load last message for preview - Bytes last_msg_hash = messages.back(); - ::LXMF::LXMessage last_msg = _message_store->load_message(last_msg_hash); - - // Create conversation item - ConversationItem item; - item.peer_hash = peer_hash; + // Create conversation item + ConversationItem item; + item.peer_hash = peer_hash; // Try to get display name from app_data, fall back to hash Bytes app_data = Identity::recall_app_data(peer_hash); @@ -234,24 +227,35 @@ void ConversationListScreen::refresh() { } else { item.peer_name = truncate_hash(peer_hash); } - } else { - item.peer_name = truncate_hash(peer_hash); - } - - // Get message content for preview - String content((const char*)last_msg.content().data(), last_msg.content().size()); - item.last_message = content.substring(0, 30); // Truncate to 30 chars - if (content.length() > 30) { - item.last_message += "..."; - } - - item.timestamp = (uint32_t)last_msg.timestamp(); - item.timestamp_str = format_timestamp(item.timestamp); - item.unread_count = 0; // TODO: Track unread count - - _conversations.push_back(item); - create_conversation_item(item); - } + } else { + item.peer_name = truncate_hash(peer_hash); + } + + // Prefer preview cached in the index; only fall back to metadata if needed. + if (info.last_message_preview_cstr()[0] != '\0') { + item.last_message = info.last_message_preview_cstr(); + } else if (last_msg_hash) { + ::LXMF::MessageStore::MessageMetadata meta = _message_store->load_message_metadata(last_msg_hash); + if (meta.valid) { + String content(meta.content.c_str()); + item.last_message = content.substring(0, 30); + if (content.length() > 30) { + item.last_message += "..."; + } + } else { + item.last_message = ""; + } + } else { + item.last_message = ""; + } + + item.timestamp = (uint32_t)info.last_activity; + item.timestamp_str = format_timestamp(item.timestamp); + item.unread_count = info.unread_count; + + _conversations.push_back(item); + create_conversation_item(item); + } } void ConversationListScreen::create_conversation_item(const ConversationItem& item) { @@ -320,15 +324,49 @@ void ConversationListScreen::create_conversation_item(const ConversationItem& it } void ConversationListScreen::update_unread_count(const Bytes& peer_hash, uint16_t unread_count) { - LVGL_LOCK(); - // Find conversation and update - for (auto& conv : _conversations) { - if (conv.peer_hash == peer_hash) { - conv.unread_count = unread_count; - refresh(); // Redraw list - break; - } - } + LVGL_LOCK(); + for (size_t i = 0; i < _conversations.size(); ++i) { + auto& conv = _conversations[i]; + if (conv.peer_hash != peer_hash) { + continue; + } + + conv.unread_count = unread_count; + if (i < _conversation_containers.size()) { + lv_obj_t* container = _conversation_containers[i]; + if (container) { + uint32_t child_count = lv_obj_get_child_cnt(container); + lv_obj_t* badge = (child_count > 3) ? lv_obj_get_child(container, child_count - 1) : nullptr; + + if (unread_count == 0) { + if (badge) { + lv_obj_del(badge); + } + } else { + if (badge && lv_obj_get_child_cnt(badge) > 0) { + lv_obj_t* label_count = lv_obj_get_child(badge, 0); + if (label_count) { + lv_label_set_text_fmt(label_count, "%d", unread_count); + } + } else { + lv_obj_t* new_badge = lv_obj_create(container); + lv_obj_set_size(new_badge, 20, 20); + lv_obj_align(new_badge, LV_ALIGN_BOTTOM_RIGHT, -6, -4); + lv_obj_set_style_bg_color(new_badge, Theme::error(), 0); + lv_obj_set_style_radius(new_badge, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(new_badge, 0, 0); + lv_obj_set_style_pad_all(new_badge, 0, 0); + + lv_obj_t* label_count = lv_label_create(new_badge); + lv_label_set_text_fmt(label_count, "%d", unread_count); + lv_obj_center(label_count); + lv_obj_set_style_text_color(label_count, lv_color_white(), 0); + } + } + } + } + break; + } } void ConversationListScreen::set_conversation_selected_callback(ConversationSelectedCallback callback) { diff --git a/src/UI/LXMF/UIManager.cpp b/src/UI/LXMF/UIManager.cpp index 32af37c8..56fd3134 100644 --- a/src/UI/LXMF/UIManager.cpp +++ b/src/UI/LXMF/UIManager.cpp @@ -216,12 +216,6 @@ bool UIManager::init() { void UIManager::update() { LVGL_LOCK(); - // Process outbound LXMF messages - _router.process_outbound(); - - // Process inbound LXMF messages - _router.process_inbound(); - // Update status indicators (WiFi/battery) on conversation list static uint32_t last_status_update = 0; uint32_t now = millis(); @@ -587,9 +581,10 @@ void UIManager::on_message_received(::LXMF::LXMessage& message) { } } - // Update conversation list unread count - // TODO: Track unread counts - _conversation_list_screen->refresh(); + // Update conversation list only while it is visible. + if (_current_screen == SCREEN_CONVERSATION_LIST) { + _conversation_list_screen->refresh(); + } INFO(" Message processed"); } diff --git a/test/test_lxmf/python/conftest.py b/test/test_lxmf/python/conftest.py new file mode 100644 index 00000000..5e4c6379 --- /dev/null +++ b/test/test_lxmf/python/conftest.py @@ -0,0 +1,49 @@ +""" +Shared fixtures for LXMF interop tests (C++ <-> Python). + +Loads test vectors from: + /tmp/lxmf_cpp_vectors.json - generated by C++ testCppGenerateVectors() + /tmp/lxmf_test_vectors_full.json - generated by generate_vectors.py +""" + +import json +import pytest +import RNS + + +@pytest.fixture(scope="session") +def cpp_vectors(): + """Load C++ generated test vectors.""" + path = "/tmp/lxmf_cpp_vectors.json" + try: + with open(path) as f: + return json.load(f) + except FileNotFoundError: + pytest.skip(f"C++ vectors not found at {path}. Run: pio test -e native17 -f test_lxmf") + + +@pytest.fixture(scope="session") +def python_vectors(): + """Load Python generated test vectors.""" + path = "/tmp/lxmf_test_vectors_full.json" + try: + with open(path) as f: + return json.load(f) + except FileNotFoundError: + pytest.skip(f"Python vectors not found at {path}. Run: python3 generate_vectors.py") + + +def get_vector_by_name(vectors, name): + """Find a vector by name in a vector list.""" + for v in vectors: + if v["name"] == name: + return v + pytest.fail(f"Vector '{name}' not found in vectors") + + +def identity_from_pub_hex(pub_hex): + """Reconstruct an RNS Identity from a hex-encoded public key.""" + pub_bytes = bytes.fromhex(pub_hex) + identity = RNS.Identity(create_keys=False) + identity.load_public_key(pub_bytes) + return identity diff --git a/test/test_lxmf/python/generate_vectors.py b/test/test_lxmf/python/generate_vectors.py new file mode 100644 index 00000000..87c66150 --- /dev/null +++ b/test/test_lxmf/python/generate_vectors.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Generate LXMF test vectors for C++ interoperability testing. + +Produces: + /tmp/lxmf_test_vector.json - single basic vector (consumed by testPythonInterop) + /tmp/lxmf_test_vectors_full.json - all test vectors (consumed by expanded interop tests) + +Usage: + python3 generate_vectors.py +""" + +import json +import sys +import time + +try: + import RNS + import LXMF +except ImportError: + print("Error: RNS and LXMF required. Run: pip install rns lxmf", file=sys.stderr) + sys.exit(1) + + +def make_identity(): + """Create a fresh RNS Identity.""" + return RNS.Identity() + + +def make_destinations(source_id, dest_id): + """Create source and destination objects (OUT direction to avoid Transport init).""" + source = RNS.Destination(source_id, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + dest = RNS.Destination(dest_id, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + return source, dest + + +def identity_to_dict(identity): + """Extract identity key material as hex strings.""" + return { + "prv": identity.get_private_key().hex(), + "pub": identity.get_public_key().hex(), + } + + +def message_to_vector(msg, source_id, dest_id, name, extra=None): + """Convert a packed LXMessage to a test vector dict.""" + vector = { + "name": name, + "source_identity_prv": source_id.get_private_key().hex(), + "source_identity_pub": source_id.get_public_key().hex(), + "dest_identity_pub": dest_id.get_public_key().hex(), + "source_hash": msg.source_hash.hex(), + "dest_hash": msg.destination_hash.hex(), + "packed": msg.packed.hex(), + "content": msg.content.decode("utf-8", errors="replace") if msg.content else "", + "title": msg.title.decode("utf-8", errors="replace") if msg.title else "", + "message_hash": msg.hash.hex(), + "timestamp": msg.timestamp, + "has_stamp": msg.stamp is not None and len(msg.stamp) > 0, + "stamp": msg.stamp.hex() if msg.stamp and len(msg.stamp) > 0 else "", + } + + # Encode fields — Python LXMF uses integer keys + if msg.fields: + fields_dict = {} + for k, v in msg.fields.items(): + key_hex = k.to_bytes(1, "big").hex() if isinstance(k, int) else k.hex() + val_hex = v.hex() if isinstance(v, (bytes, bytearray)) else str(v).encode().hex() + fields_dict[key_hex] = val_hex + vector["fields"] = fields_dict + else: + vector["fields"] = {} + + if extra: + vector.update(extra) + + return vector + + +def generate_basic(): + """Basic message — simple title + content, DIRECT method.""" + source_id = make_identity() + dest_id = make_identity() + source, dest = make_destinations(source_id, dest_id) + + msg = LXMF.LXMessage( + dest, source, + "Hello from Python LXMF!", + "Test Message", + desired_method=LXMF.LXMessage.DIRECT, + ) + msg.pack() + + return message_to_vector(msg, source_id, dest_id, "basic") + + +def generate_empty(): + """Empty content and title.""" + source_id = make_identity() + dest_id = make_identity() + source, dest = make_destinations(source_id, dest_id) + + msg = LXMF.LXMessage( + dest, source, + "", + "", + desired_method=LXMF.LXMessage.DIRECT, + ) + msg.pack() + + return message_to_vector(msg, source_id, dest_id, "empty") + + +def generate_with_fields(): + """Message with integer-keyed fields (standard LXMF format).""" + source_id = make_identity() + dest_id = make_identity() + source, dest = make_destinations(source_id, dest_id) + + fields = { + 0x01: b"field_value_one", + 0x02: b"field_value_two", + 0x03: b"field_value_three", + } + + msg = LXMF.LXMessage( + dest, source, + "Content with fields", + "Fields Test", + fields=fields, + desired_method=LXMF.LXMessage.DIRECT, + ) + msg.pack() + + return message_to_vector(msg, source_id, dest_id, "with_fields") + + +def generate_large(): + """Large content (>319 bytes, would be RESOURCE representation).""" + source_id = make_identity() + dest_id = make_identity() + source, dest = make_destinations(source_id, dest_id) + + # 500 bytes of content + content = "A" * 500 + msg = LXMF.LXMessage( + dest, source, + content, + "Large Message", + desired_method=LXMF.LXMessage.DIRECT, + ) + msg.pack() + + return message_to_vector(msg, source_id, dest_id, "large") + + +def generate_unicode(): + """Unicode content and title.""" + source_id = make_identity() + dest_id = make_identity() + source, dest = make_destinations(source_id, dest_id) + + msg = LXMF.LXMessage( + dest, source, + "Hello \u4e16\u754c! \U0001f680 Caf\u00e9", + "\u2603 Unicode Title \U0001f4ac", + desired_method=LXMF.LXMessage.DIRECT, + ) + msg.pack() + + return message_to_vector(msg, source_id, dest_id, "unicode") + + +def generate_with_stamp(): + """Message with a stamp (proof-of-work). + + LXMF stamps are appended as the 5th element of the payload array. + Hash and signature are computed over the 4-element payload (WITHOUT stamp), + then the stamp is appended for the wire format. + """ + from RNS.vendor import umsgpack + + source_id = make_identity() + dest_id = make_identity() + source, dest = make_destinations(source_id, dest_id) + + msg = LXMF.LXMessage( + dest, source, + "Stamped message", + "Stamp Test", + desired_method=LXMF.LXMessage.DIRECT, + stamp_cost=8, # Low cost for fast test generation + ) + msg.pack() + + # Generate stamp (PoW against the message hash, which is over 4-element payload) + stamp = msg.get_stamp(timeout=30) + + # Hash and signature from pack() are already correct (over 4-element payload). + # Just append stamp as 5th element to the wire payload. + dest_hash = msg.packed[:16] + src_hash = msg.packed[16:32] + original_sig = msg.packed[32:96] + payload_bytes = msg.packed[96:] + + # Append stamp as 5th element + payload_arr = umsgpack.unpackb(payload_bytes) + payload_arr.append(stamp) + new_payload_bytes = umsgpack.packb(payload_arr) + + # Assemble with original signature (computed over 4-element payload) + new_packed = dest_hash + src_hash + original_sig + new_payload_bytes + + vector = { + "name": "with_stamp", + "source_identity_prv": source_id.get_private_key().hex(), + "source_identity_pub": source_id.get_public_key().hex(), + "dest_identity_pub": dest_id.get_public_key().hex(), + "source_hash": src_hash.hex(), + "dest_hash": dest_hash.hex(), + "packed": new_packed.hex(), + "content": "Stamped message", + "title": "Stamp Test", + "message_hash": msg.hash.hex(), # Hash over 4-element payload + "timestamp": msg.timestamp, + "has_stamp": True, + "stamp": stamp.hex(), + "fields": {}, + } + + return vector + + +def main(): + print(f"Generating LXMF test vectors (RNS {RNS.__version__}, LXMF {LXMF.__version__})", + file=sys.stderr) + + vectors = [] + + # Generate all test vectors + print(" Generating basic...", file=sys.stderr) + basic = generate_basic() + vectors.append(basic) + + print(" Generating empty...", file=sys.stderr) + vectors.append(generate_empty()) + + print(" Generating with_fields...", file=sys.stderr) + vectors.append(generate_with_fields()) + + print(" Generating large...", file=sys.stderr) + vectors.append(generate_large()) + + print(" Generating unicode...", file=sys.stderr) + vectors.append(generate_unicode()) + + print(" Generating with_stamp (PoW, may take a moment)...", file=sys.stderr) + vectors.append(generate_with_stamp()) + + # Write single vector (backward compatible with existing testPythonInterop) + single_path = "/tmp/lxmf_test_vector.json" + with open(single_path, "w") as f: + json.dump(basic, f, indent=2) + print(f" Wrote {single_path}", file=sys.stderr) + + # Write full vectors + full_path = "/tmp/lxmf_test_vectors_full.json" + with open(full_path, "w") as f: + json.dump(vectors, f, indent=2) + print(f" Wrote {full_path} ({len(vectors)} vectors)", file=sys.stderr) + + print("Done.", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/test/test_lxmf/python/test_interop.py b/test/test_lxmf/python/test_interop.py new file mode 100644 index 00000000..d41ac072 --- /dev/null +++ b/test/test_lxmf/python/test_interop.py @@ -0,0 +1,290 @@ +""" +LXMF interop tests: validate C++ output in Python. + +Reads /tmp/lxmf_cpp_vectors.json (generated by C++ testCppGenerateVectors) +and verifies that Python LXMF can correctly unpack and validate the messages. + +Also tests bidirectional roundtrip: Python → C++ → Python. +""" + +import hashlib +import json +import pytest + +import RNS +import LXMF +from RNS.vendor import umsgpack + +from conftest import get_vector_by_name, identity_from_pub_hex + + +def register_source_identity(vector): + """Register source identity so Python LXMF can validate signatures.""" + source_identity = identity_from_pub_hex(vector["source_identity_pub"]) + source_hash = bytes.fromhex(vector["source_hash"]) + RNS.Identity.remember(None, source_hash, source_identity.get_public_key()) + return source_identity + + +def validate_signature_manual(packed_bytes, pub_hex): + """Manually validate Ed25519 signature on packed LXMF message.""" + identity = identity_from_pub_hex(pub_hex) + + dest_hash = packed_bytes[:16] + src_hash = packed_bytes[16:32] + signature = packed_bytes[32:96] + payload = packed_bytes[96:] + + # Hash and signature are computed over the payload WITHOUT stamp + decoded = umsgpack.unpackb(payload) + if len(decoded) > 4: + # Strip stamp, repack + payload = umsgpack.packb(decoded[:4]) + + hashed_part = dest_hash + src_hash + payload + msg_hash = hashlib.sha256(hashed_part).digest() + signed_part = hashed_part + msg_hash + + return identity.validate(signature, signed_part) + + +# ==================== C++ → Python Tests ==================== + + +class TestCppBasicUnpack: + """Test that Python can unpack basic C++ messages.""" + + def test_content_and_title(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_basic") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + assert msg.content_as_string() == v["content"] + assert msg.title_as_string() == v["title"] + + def test_empty_content(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_empty") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + assert msg.content_as_string() == "" + assert msg.title_as_string() == "" + + def test_unicode_content(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_unicode") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + assert msg.content_as_string() == v["content"] + assert msg.title_as_string() == v["title"] + + +class TestCppSignatureValid: + """Test that C++ Ed25519 signatures are valid.""" + + def test_basic_signature(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_basic") + packed = bytes.fromhex(v["packed"]) + assert validate_signature_manual(packed, v["source_identity_pub"]) + + def test_empty_signature(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_empty") + packed = bytes.fromhex(v["packed"]) + assert validate_signature_manual(packed, v["source_identity_pub"]) + + def test_fields_signature(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_with_fields") + packed = bytes.fromhex(v["packed"]) + assert validate_signature_manual(packed, v["source_identity_pub"]) + + def test_unicode_signature(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_unicode") + packed = bytes.fromhex(v["packed"]) + assert validate_signature_manual(packed, v["source_identity_pub"]) + + +class TestCppHashMatches: + """Test that Python-computed hash matches C++ reported hash.""" + + def _verify_hash(self, vector): + packed = bytes.fromhex(vector["packed"]) + dest_hash = packed[:16] + src_hash = packed[16:32] + payload = packed[96:] + + hashed_part = dest_hash + src_hash + payload + computed_hash = hashlib.sha256(hashed_part).digest() + + assert computed_hash.hex() == vector["message_hash"] + + def test_basic_hash(self, cpp_vectors): + self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_basic")) + + def test_empty_hash(self, cpp_vectors): + self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_empty")) + + def test_fields_hash(self, cpp_vectors): + self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_with_fields")) + + def test_unicode_hash(self, cpp_vectors): + self._verify_hash(get_vector_by_name(cpp_vectors, "cpp_unicode")) + + +class TestCppFields: + """Test that C++ field encoding can be decoded by Python.""" + + def test_fields_present(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_with_fields") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + # C++ uses binary keys (Bytes), Python LXMF unpacks them as bytes + fields = msg.get_fields() + assert fields is not None + assert len(fields) == v["fields_count"] + + def test_field_values(self, cpp_vectors): + v = get_vector_by_name(cpp_vectors, "cpp_with_fields") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + fields = msg.get_fields() + + # Check each field from the vector + for key_hex, val_hex in v["fields"].items(): + key_bytes = bytes.fromhex(key_hex) + val_bytes = bytes.fromhex(val_hex) + assert key_bytes in fields, f"Field key {key_hex} not found in unpacked fields" + assert fields[key_bytes] == val_bytes, f"Field value mismatch for key {key_hex}" + + +class TestCppStamp: + """Test that C++ generated stamps are valid per Python LXMF.""" + + def test_stamp_present(self, cpp_vectors): + """C++ stamped message should have a 32-byte stamp after unpack.""" + v = get_vector_by_name(cpp_vectors, "cpp_with_stamp") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + assert msg.stamp is not None, "Stamp missing from unpacked message" + assert len(msg.stamp) == 32, f"Stamp wrong size: {len(msg.stamp)}" + + def test_stamp_bytes_match(self, cpp_vectors): + """Stamp bytes from unpack should match what C++ reported.""" + v = get_vector_by_name(cpp_vectors, "cpp_with_stamp") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + expected_stamp = bytes.fromhex(v["stamp"]) + assert msg.stamp == expected_stamp + + def test_stamp_validates(self, cpp_vectors): + """Python LXStamper should accept the C++ generated stamp.""" + from LXMF import LXStamper + + v = get_vector_by_name(cpp_vectors, "cpp_with_stamp") + register_source_identity(v) + + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + stamp_cost = v["stamp_cost"] + assert msg.validate_stamp(stamp_cost), ( + f"Python rejected C++ stamp (cost={stamp_cost})" + ) + + def test_stamp_signature_valid(self, cpp_vectors): + """Signature should be valid (computed over 4-element payload without stamp).""" + v = get_vector_by_name(cpp_vectors, "cpp_with_stamp") + packed = bytes.fromhex(v["packed"]) + assert validate_signature_manual(packed, v["source_identity_pub"]) + + def test_stamp_hash_matches(self, cpp_vectors): + """Hash should be computed over the payload WITHOUT stamp.""" + v = get_vector_by_name(cpp_vectors, "cpp_with_stamp") + packed = bytes.fromhex(v["packed"]) + + dest_hash = packed[:16] + src_hash = packed[16:32] + payload = packed[96:] + + # Strip stamp (5th element) before computing hash + decoded = umsgpack.unpackb(payload) + if len(decoded) > 4: + payload = umsgpack.packb(decoded[:4]) + + hashed_part = dest_hash + src_hash + payload + computed_hash = hashlib.sha256(hashed_part).digest() + assert computed_hash.hex() == v["message_hash"] + + +class TestBidirectionalRoundtrip: + """Test that Python→C++→Python roundtrip preserves data. + + Python generates vectors, C++ unpacks and repacks them (testCppGenerateVectors), + then Python unpacks the C++ output. We verify the original Python vectors + match the C++ output. + """ + + def test_basic_roundtrip_hash(self, python_vectors, cpp_vectors): + """Verify hash computation is consistent across implementations. + + Note: hash won't be identical because C++ generates new messages + (different keys/timestamps). Instead we verify that Python can + independently compute the same hash from the C++ packed bytes. + Hash is always over the 4-element payload (without stamp). + """ + for v in cpp_vectors: + packed = bytes.fromhex(v["packed"]) + dest_hash = packed[:16] + src_hash = packed[16:32] + payload = packed[96:] + + # Strip stamp (5th element) if present before computing hash + decoded = umsgpack.unpackb(payload) + if len(decoded) > 4: + payload = umsgpack.packb(decoded[:4]) + + hashed_part = dest_hash + src_hash + payload + py_hash = hashlib.sha256(hashed_part).digest() + + assert py_hash.hex() == v["message_hash"], ( + f"Hash mismatch for {v['name']}: " + f"Python={py_hash.hex()}, C++={v['message_hash']}" + ) + + def test_all_cpp_vectors_unpack(self, cpp_vectors): + """Every C++ vector should unpack without error in Python.""" + for v in cpp_vectors: + register_source_identity(v) + packed = bytes.fromhex(v["packed"]) + msg = LXMF.LXMessage.unpack_from_bytes(packed) + + assert msg.content_as_string() == v["content"], ( + f"Content mismatch for {v['name']}" + ) + assert msg.title_as_string() == v["title"], ( + f"Title mismatch for {v['name']}" + ) + + def test_all_cpp_signatures_valid(self, cpp_vectors): + """Every C++ vector should have a valid signature.""" + for v in cpp_vectors: + packed = bytes.fromhex(v["packed"]) + assert validate_signature_manual(packed, v["source_identity_pub"]), ( + f"Signature invalid for {v['name']}" + ) diff --git a/test/test_lxmf/run_interop.sh b/test/test_lxmf/run_interop.sh new file mode 100755 index 00000000..597d5d6b --- /dev/null +++ b/test/test_lxmf/run_interop.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# E2E LXMF interop test: Python <-> C++ +# +# Runs the full bidirectional test pipeline: +# 1. Python generates test vectors → /tmp/lxmf_test_vector*.json +# 2. C++ unpacks Python vectors, generates its own → /tmp/lxmf_cpp_vectors.json +# 3. Python validates C++ vectors +# +# Usage: bash run_interop.sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "=== LXMF E2E Interop Tests ===" +echo " Script dir: $SCRIPT_DIR" +echo " Repo dir: $REPO_DIR" +echo + +# Step 1: Generate Python test vectors +echo "--- Step 1: Generate Python test vectors ---" +python3 "$SCRIPT_DIR/python/generate_vectors.py" +echo + +# Step 2: Run C++ tests (consumes Python vectors, generates C++ vectors) +echo "--- Step 2: Run C++ tests ---" +cd "$REPO_DIR" +pio test -e native17 -f test_lxmf 2>&1 | grep -E "test.*\[|SUMMARY|======" || true +echo + +# Step 3: Run Python validation tests +echo "--- Step 3: Run Python validation tests ---" +cd "$SCRIPT_DIR" +python3 -m pytest python/test_interop.py -v +echo + +echo "=== All LXMF interop tests passed ===" diff --git a/test/test_lxmf/test_main.cpp b/test/test_lxmf/test_main.cpp index c0174376..19f64ae1 100644 --- a/test/test_lxmf/test_main.cpp +++ b/test/test_lxmf/test_main.cpp @@ -7,6 +7,7 @@ #include "Bytes.h" #include "Identity.h" #include "Destination.h" +#include "Transport.h" #include "LXMF/LXMessage.h" #include "LXMF/LXMRouter.h" #include "LXMF/MessageStore.h" @@ -178,77 +179,332 @@ void testLXMessageFields() { TEST_ASSERT_TRUE(*val2 == Bytes("value2")); } -void testPythonInterop() { - // Test unpacking a message created by Python LXMF - // This validates byte-level compatibility between C++ and Python implementations +// ==================== Python Interop Helpers ==================== - // Read the test vector generated by lxmf_simple_test.py - std::ifstream file("/tmp/lxmf_test_vector.json"); - if (!file.is_open()) { - TEST_IGNORE_MESSAGE("Python test vector not found at /tmp/lxmf_test_vector.json. Run: python3 test/test_interop/python/lxmf_simple_test.py"); - return; - } +// Load a single vector from a JSON file +static bool loadSingleVector(const char* path, JsonDocument& doc) { + std::ifstream file(path); + if (!file.is_open()) return false; + std::stringstream buffer; + buffer << file.rdbuf(); + file.close(); + return !deserializeJson(doc, buffer.str()); +} - // Parse JSON +// Load the full vectors array, return a specific vector by name +static bool loadVectorByName(const char* name, JsonDocument& doc) { + std::ifstream file("/tmp/lxmf_test_vectors_full.json"); + if (!file.is_open()) return false; std::stringstream buffer; buffer << file.rdbuf(); file.close(); + if (deserializeJson(doc, buffer.str())) return false; + + // Find vector with matching name + JsonArray arr = doc.as(); + for (JsonObject v : arr) { + if (strcmp(v["name"].as(), name) == 0) { + // Copy to doc root (overwrite array with single object) + JsonDocument single; + single.set(v); + doc = single; + return true; + } + } + return false; +} - JsonDocument doc; - DeserializationError error = deserializeJson(doc, buffer.str()); - TEST_ASSERT_FALSE(error); +// Unpack a vector and register its source identity, returning the unpacked message +static LXMessage unpackVector(JsonDocument& doc) { + Bytes packed; + packed.assignHex(doc["packed"].as()); - // Extract test data - const char* packed_hex = doc["packed"]; - const char* content_str = doc["content"]; - const char* title_str = doc["title"]; + Bytes source_pub; + source_pub.assignHex(doc["source_identity_pub"].as()); + Bytes source_hash; + source_hash.assignHex(doc["source_hash"].as()); - // Convert hex strings to Bytes - Bytes packed; - packed.assignHex(packed_hex); + // Register source identity for signature validation + Identity::remember(Identity::get_random_hash(), source_hash, source_pub); - // Extract additional test data for validation - const char* source_identity_pub_hex = doc["source_identity_pub"]; - const char* source_hash_hex = doc["source_hash"]; - const char* dest_hash_hex = doc["dest_hash"]; - const char* message_hash_hex = doc["message_hash"]; + return LXMessage::unpack_from_bytes(packed); +} - Bytes source_pub; - source_pub.assignHex(source_identity_pub_hex); +// ==================== Python → C++ Interop Tests ==================== + +void testPythonInterop() { + // Test unpacking a basic message created by Python LXMF + + JsonDocument doc; + if (!loadSingleVector("/tmp/lxmf_test_vector.json", doc)) { + TEST_IGNORE_MESSAGE("Python test vector not found. Run: python3 test/test_lxmf/python/generate_vectors.py"); + return; + } + + const char* content_str = doc["content"]; + const char* title_str = doc["title"]; + Bytes expected_hash; + expected_hash.assignHex(doc["message_hash"].as()); Bytes source_hash; - source_hash.assignHex(source_hash_hex); + source_hash.assignHex(doc["source_hash"].as()); Bytes dest_hash; - dest_hash.assignHex(dest_hash_hex); - Bytes expected_hash; - expected_hash.assignHex(message_hash_hex); + dest_hash.assignHex(doc["dest_hash"].as()); - // Remember the source identity so signature can be validated - Bytes packet_hash = Identity::get_random_hash(); - Identity::remember(packet_hash, source_hash, source_pub); + LXMessage unpacked = unpackVector(doc); - // Unpack the Python-generated message - LXMessage unpacked = LXMessage::unpack_from_bytes(packed); - - // Verify hash matches + // Verify hash TEST_ASSERT_EQUAL_size_t(32, unpacked.hash().size()); TEST_ASSERT_EQUAL_INT(0, memcmp(expected_hash.data(), unpacked.hash().data(), 32)); - // Verify content matches + // Verify content std::string content_cpp(reinterpret_cast(unpacked.content().data()), unpacked.content().size()); TEST_ASSERT_EQUAL_STRING(content_str, content_cpp.c_str()); - // Verify title matches + // Verify title std::string title_cpp(reinterpret_cast(unpacked.title().data()), unpacked.title().size()); TEST_ASSERT_EQUAL_STRING(title_str, title_cpp.c_str()); - // Verify signature was validated + // Verify signature TEST_ASSERT_TRUE(unpacked.signature_validated()); - // Verify hashes match + // Verify source/dest hashes TEST_ASSERT_EQUAL_INT(0, memcmp(source_hash.data(), unpacked.source_hash().data(), 16)); TEST_ASSERT_EQUAL_INT(0, memcmp(dest_hash.data(), unpacked.destination_hash().data(), 16)); } +void testPythonInteropEmpty() { + // Test unpacking a Python message with empty content and title + + JsonDocument doc; + if (!loadVectorByName("empty", doc)) { + TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py"); + return; + } + + LXMessage unpacked = unpackVector(doc); + + TEST_ASSERT_EQUAL_size_t(0, unpacked.content().size()); + TEST_ASSERT_EQUAL_size_t(0, unpacked.title().size()); + TEST_ASSERT_TRUE(unpacked.signature_validated()); + + // Verify hash + Bytes expected_hash; + expected_hash.assignHex(doc["message_hash"].as()); + TEST_ASSERT_EQUAL_INT(0, memcmp(expected_hash.data(), unpacked.hash().data(), 32)); +} + +void testPythonInteropLarge() { + // Test unpacking a large Python message (>319 bytes content) + + JsonDocument doc; + if (!loadVectorByName("large", doc)) { + TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py"); + return; + } + + LXMessage unpacked = unpackVector(doc); + + // Verify content is 500 'A' characters + TEST_ASSERT_EQUAL_size_t(500, unpacked.content().size()); + for (size_t i = 0; i < unpacked.content().size(); ++i) { + TEST_ASSERT_EQUAL_UINT8('A', unpacked.content().data()[i]); + } + TEST_ASSERT_TRUE(unpacked.signature_validated()); +} + +void testPythonInteropUnicode() { + // Test unpacking a Python message with UTF-8 content + + JsonDocument doc; + if (!loadVectorByName("unicode", doc)) { + TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py"); + return; + } + + const char* content_str = doc["content"]; + const char* title_str = doc["title"]; + + LXMessage unpacked = unpackVector(doc); + + std::string content_cpp(reinterpret_cast(unpacked.content().data()), unpacked.content().size()); + TEST_ASSERT_EQUAL_STRING(content_str, content_cpp.c_str()); + + std::string title_cpp(reinterpret_cast(unpacked.title().data()), unpacked.title().size()); + TEST_ASSERT_EQUAL_STRING(title_str, title_cpp.c_str()); + TEST_ASSERT_TRUE(unpacked.signature_validated()); +} + +void testPythonInteropStamp() { + // Test unpacking a Python message with a stamp (5th element in payload array) + + JsonDocument doc; + if (!loadVectorByName("with_stamp", doc)) { + TEST_IGNORE_MESSAGE("Full vectors not found. Run: python3 test/test_lxmf/python/generate_vectors.py"); + return; + } + + LXMessage unpacked = unpackVector(doc); + + // Verify stamp is present (32 bytes) + TEST_ASSERT_EQUAL_size_t(32, unpacked.stamp().size()); + TEST_ASSERT_TRUE(unpacked.signature_validated()); + + // Verify stamp bytes match + Bytes expected_stamp; + expected_stamp.assignHex(doc["stamp"].as()); + TEST_ASSERT_EQUAL_INT(0, memcmp(expected_stamp.data(), unpacked.stamp().data(), 32)); +} + +// ==================== C++ → Python Vector Generation ==================== + +void testCppGenerateVectors() { + // Generate test vectors from C++ for Python to validate. + // Writes /tmp/lxmf_cpp_vectors.json + + JsonDocument output; + JsonArray vectors = output.to(); + + // --- Vector 1: basic message --- + { + Identity source_identity; + Identity dest_identity; + Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + + LXMessage msg(dest, source, Bytes("Hello from C++!"), Bytes("C++ Test")); + msg.pack(); + + JsonObject v = vectors.add(); + v["name"] = "cpp_basic"; + v["source_identity_pub"] = source_identity.get_public_key().toHex(); + v["source_hash"] = source.hash().toHex(); + v["dest_hash"] = dest.hash().toHex(); + v["packed"] = msg.packed().toHex(); + v["content"] = "Hello from C++!"; + v["title"] = "C++ Test"; + v["message_hash"] = msg.hash().toHex(); + v["timestamp"] = msg.timestamp(); + } + + // --- Vector 2: empty content --- + { + Identity source_identity; + Identity dest_identity; + Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + + LXMessage msg(dest, source, Bytes(), Bytes()); + msg.pack(); + + JsonObject v = vectors.add(); + v["name"] = "cpp_empty"; + v["source_identity_pub"] = source_identity.get_public_key().toHex(); + v["source_hash"] = source.hash().toHex(); + v["dest_hash"] = dest.hash().toHex(); + v["packed"] = msg.packed().toHex(); + v["content"] = ""; + v["title"] = ""; + v["message_hash"] = msg.hash().toHex(); + v["timestamp"] = msg.timestamp(); + } + + // --- Vector 3: with fields (binary keys) --- + { + Identity source_identity; + Identity dest_identity; + Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + + LXMessage msg(dest, source, Bytes("Content with fields"), Bytes("Fields Test")); + msg.fields_set(Bytes("field1"), Bytes("value1")); + msg.fields_set(Bytes("field2"), Bytes("value2")); + msg.pack(); + + JsonObject v = vectors.add(); + v["name"] = "cpp_with_fields"; + v["source_identity_pub"] = source_identity.get_public_key().toHex(); + v["source_hash"] = source.hash().toHex(); + v["dest_hash"] = dest.hash().toHex(); + v["packed"] = msg.packed().toHex(); + v["content"] = "Content with fields"; + v["title"] = "Fields Test"; + v["message_hash"] = msg.hash().toHex(); + v["timestamp"] = msg.timestamp(); + v["fields_count"] = (int)msg.fields_count(); + + // Store field keys/values as hex for Python to verify + JsonObject fields = v["fields"].to(); + fields[Bytes("field1").toHex()] = Bytes("value1").toHex(); + fields[Bytes("field2").toHex()] = Bytes("value2").toHex(); + } + + // --- Vector 4: unicode content --- + { + Identity source_identity; + Identity dest_identity; + Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + + // UTF-8 encoded content + const char* utf8_content = "Hello \xe4\xb8\x96\xe7\x95\x8c! Caf\xc3\xa9"; + const char* utf8_title = "\xe2\x98\x83 Unicode"; + + LXMessage msg(dest, source, Bytes(utf8_content), Bytes(utf8_title)); + msg.pack(); + + JsonObject v = vectors.add(); + v["name"] = "cpp_unicode"; + v["source_identity_pub"] = source_identity.get_public_key().toHex(); + v["source_hash"] = source.hash().toHex(); + v["dest_hash"] = dest.hash().toHex(); + v["packed"] = msg.packed().toHex(); + v["content"] = utf8_content; + v["title"] = utf8_title; + v["message_hash"] = msg.hash().toHex(); + v["timestamp"] = msg.timestamp(); + } + + // --- Vector 5: with stamp --- + { + Identity source_identity; + Identity dest_identity; + Destination source(source_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + Destination dest(dest_identity, RNS::Type::Destination::IN, RNS::Type::Destination::SINGLE, "lxmf", "delivery"); + + LXMessage msg(dest, source, Bytes("Stamped from C++"), Bytes("Stamp Test")); + msg.set_stamp_cost(8); + msg.pack(); // First pack to get hash + msg.generate_stamp(); // PoW against that hash + const Bytes& packed = msg.pack(); // Re-pack with stamp in payload + + TEST_ASSERT_EQUAL_size_t(32, msg.stamp().size()); + + JsonObject v = vectors.add(); + v["name"] = "cpp_with_stamp"; + v["source_identity_pub"] = source_identity.get_public_key().toHex(); + v["source_hash"] = source.hash().toHex(); + v["dest_hash"] = dest.hash().toHex(); + v["packed"] = packed.toHex(); + v["content"] = "Stamped from C++"; + v["title"] = "Stamp Test"; + v["message_hash"] = msg.hash().toHex(); + v["timestamp"] = msg.timestamp(); + v["stamp"] = msg.stamp().toHex(); + v["stamp_cost"] = msg.stamp_cost(); + } + + // Write to file + std::ofstream outfile("/tmp/lxmf_cpp_vectors.json"); + TEST_ASSERT_TRUE(outfile.is_open()); + serializeJsonPretty(output, outfile); + outfile.close(); + + // Verify file was written + std::ifstream verify("/tmp/lxmf_cpp_vectors.json"); + TEST_ASSERT_TRUE(verify.is_open()); + verify.close(); +} + void testLXMRouterCreation() { // Test router initialization Identity router_identity; @@ -475,7 +731,10 @@ void tearDown(void) { int runUnityTests(void) { UNITY_BEGIN(); - // Suite-level setup - register filesystem for MessageStore tests + // Suite-level setup - initialize pools and register filesystem + Identity::init_known_destinations_pool(); + Transport::init_pools(); + RNS::FileSystem lxmf_filesystem = new ::FileSystem(); ((::FileSystem*)lxmf_filesystem.get())->init(); RNS::Utilities::OS::register_filesystem(lxmf_filesystem); @@ -487,6 +746,11 @@ int runUnityTests(void) { RUN_TEST(testLXMessageEmptyContent); RUN_TEST(testLXMessageFields); RUN_TEST(testPythonInterop); + RUN_TEST(testPythonInteropEmpty); + RUN_TEST(testPythonInteropLarge); + RUN_TEST(testPythonInteropUnicode); + RUN_TEST(testPythonInteropStamp); + RUN_TEST(testCppGenerateVectors); RUN_TEST(testLXMRouterCreation); // Skip router tests that require full RNS stack //RUN_TEST(testLXMRouterOutbound);