From ea938e8e840a518e3a3d0bc7fcd19a353be104ae Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 21:10:49 -0700 Subject: [PATCH] Preserve ACK ownership across contact relocation and retirement --- .github/workflows/run-unit-tests.yml | 1 + examples/companion_radio/MyMesh.cpp | 31 +- examples/companion_radio/MyMesh.h | 3 +- examples/simple_secure_chat/main.cpp | 9 +- src/helpers/BaseChatMesh.cpp | 50 +- src/helpers/BaseChatMesh.h | 30 +- test/fixtures/companion_ack_delivery/test.cpp | 14 +- .../mocks/Identity.h | 2 + .../companion_contact_lifetime/mocks/Mesh.h | 6 + .../companion_contact_lifetime/mocks/Utils.h | 2 + .../companion_contact_lifetime/test.cpp | 650 ++++++++++++++++++ test/test_companion_ack_delivery.py | 2 +- test/test_companion_contact_lifetime.py | 142 ++++ ..._companion_contact_persistence_contract.py | 13 +- .../test_contact_import.cpp | 2 +- 15 files changed, 907 insertions(+), 50 deletions(-) create mode 100644 test/fixtures/companion_contact_lifetime/mocks/Identity.h create mode 100644 test/fixtures/companion_contact_lifetime/mocks/Mesh.h create mode 100644 test/fixtures/companion_contact_lifetime/mocks/Utils.h create mode 100644 test/fixtures/companion_contact_lifetime/test.cpp create mode 100644 test/test_companion_contact_lifetime.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 0dff20bef..0b729fbaa 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -131,6 +131,7 @@ jobs: python3 -B test/test_companion_primary_radio_persistence.py -v python3 -B test/test_companion_response_bounds.py -v python3 -B test/test_companion_ack_delivery.py -v + python3 -B test/test_companion_contact_lifetime.py -v python3 -B test/test_companion_identity_cli.py -v python3 -B test/test_companion_uncached_storage.py -v python3 -B test/test_companion_ota_config.py -v diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f490e63a6..f989bd652 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1060,7 +1060,15 @@ MyMesh::AckTableEntry* MyMesh::findPendingTextMessage( return NULL; } -ContactInfo* MyMesh::processAck(const uint8_t *data) { +void MyMesh::onContactReferenceChanged(const ContactInfo* previous, ContactInfo* replacement) { + for (AckTableEntry* entry = expected_ack_table; + entry != expected_ack_table + EXPECTED_ACK_TABLE_SIZE; ++entry) { + if (entry->ack != 0 && entry->contact == previous) entry->contact = replacement; + } +} + +bool MyMesh::processAck(const uint8_t *data, ContactInfo*& peer) { + peer = NULL; expireExpectedAcks(); uint32_t incoming; memcpy(&incoming, data, sizeof(incoming)); @@ -1090,10 +1098,11 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { if (contact != NULL && contact->type == ADV_TYPE_CHAT) rememberOneKeyAck(*contact); #endif expireExpectedAcks(); - return contact; + peer = contact; + return true; } } - return checkConnectionsAck(data); + return checkConnectionsAck(data, peer); } #if MESH_ENABLE_ONE_KEY_DM @@ -1260,7 +1269,7 @@ void MyMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, // A prior anonymous service request may occupy a transient slot for this // key. Remove it so regular text lookup finds the durable contact first. ContactInfo* transient = lookupTransientContactByPubKey(sender.pub_key, PUB_KEY_SIZE); - if (transient != NULL) clearTransientContact(*transient); + if (transient != NULL) clearTransientContact(*transient, added); onDiscoveredContact(*added, true, OUT_PATH_UNKNOWN, NULL); } @@ -5583,7 +5592,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (scheduleContactWrite(*recipient)) { ContactInfo* transient = lookupTransientContactByPubKey( pub_key, PUB_KEY_SIZE); - if (transient != NULL) clearTransientContact(*transient); + if (transient != NULL) clearTransientContact(*transient, recipient); writeOKFrame(); } else { recipient->out_path_len = previous_out_path_len; @@ -5613,8 +5622,6 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo* transient = lookupTransientContactByPubKey( pub_key, PUB_KEY_SIZE); - ContactInfo previous_transient = {}; - if (transient != NULL) previous_transient = *transient; if (recipient != NULL) { const ContactInfo previous = *recipient; @@ -5622,7 +5629,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (scheduleContactWrite(*recipient)) { // A prior anonymous request can leave a same-key entry in the // reserved prefix. Retire it in place; never compact that prefix. - if (transient != NULL) clearTransientContact(*transient); + if (transient != NULL) clearTransientContact(*transient, recipient); updateGpsTelemetryPolicy(); writeOKFrame(); } else { @@ -5630,22 +5637,20 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_FILE_IO_ERROR); } } else { - // Promotion must free the exact transient prefix slot before lookup - // of the newly-added persistent record. Restore it on every failure. - if (transient != NULL) clearTransientContact(*transient); + // Preserve the transient and its pending ACK references until the + // persistent destination is accepted. Failure preserves the transient. if (addContact(candidate)) { ContactInfo* added = lookupPersistentContactByPubKey( candidate.id.pub_key, PUB_KEY_SIZE); if (added != NULL && scheduleContactWrite(*added)) { + if (transient != NULL) clearTransientContact(*transient, added); updateGpsTelemetryPolicy(); writeOKFrame(); } else { if (added != NULL) removeContact(*added); - if (transient != NULL) *transient = previous_transient; writeErrFrame(ERR_CODE_FILE_IO_ERROR); } } else { - if (transient != NULL) *transient = previous_transient; writeErrFrame(ERR_CODE_TABLE_FULL); } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8257e0e82..2f6c689be 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -370,13 +370,14 @@ protected: bool canMutateContacts() const override; void onContactsFull() override; bool onContactOverwrite(const ContactInfo& contact) override; + void onContactReferenceChanged(const ContactInfo* previous, ContactInfo* replacement) override; bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t* path) override; void onContactPathUpdated(const ContactInfo &contact) override; #if COMPANION_FEATURE_TEXT_TERMINAL void onContactVisit(const ContactInfo& contact) override; #endif - ContactInfo* processAck(const uint8_t *data) override; + bool processAck(const uint8_t *data, ContactInfo*& peer) override; #if MESH_ENABLE_ONE_KEY_DM void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index fe498e310..29e41fc95 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -240,18 +240,19 @@ protected: saveContacts(); } - ContactInfo* processAck(const uint8_t *data) override { - if (memcmp(data, &expected_ack_crc, 4) == 0) { // got an ACK from recipient + bool processAck(const uint8_t *data, ContactInfo*& peer) override { + peer = NULL; + if (expected_ack_crc != 0 && memcmp(data, &expected_ack_crc, 4) == 0) { // got an ACK from recipient Serial.printf(" Got ACK! (round trip: %d millis)\n", _ms->getMillis() - last_msg_sent); // NOTE: the same ACK can be received multiple times! expected_ack_crc = 0; // reset our expected hash, now that we have received ACK - return NULL; // TODO: really should return ContactInfo pointer + return true; // owned, even without a retained contact pointer } //uint32_t crc; //memcpy(&crc, data, 4); //MESH_DEBUG_PRINTLN("unknown ACK received: %08X (expected: %08X)", crc, expected_ack_crc); - return NULL; + return false; } void onMessageRecv(const ContactInfo& from, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) override { diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 1f7504966..d8a32f3f1 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -24,6 +24,12 @@ bool BaseChatMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Pack return sendFlood(pkt, delay_millis); } +// Share the value reset without replacing ContactInfo's assignment/RAII rules. +__attribute__((noinline)) +void BaseChatMesh::resetContactValue(ContactInfo& contact) { + contact = ContactInfo(); +} + bool BaseChatMesh::initializeContactStorage() { #if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) const int requested_capacity = MAX_CONTACTS + MAX_ANON_CONTACTS; @@ -48,10 +54,14 @@ bool BaseChatMesh::initializeContactStorage() { for (int i = 0; i < requested_capacity; ++i) new (&expanded_contacts[i]) ContactInfo(); for (int i = 0; i < num_contacts; ++i) { expanded_contacts[i] = contacts[i]; - contacts[i] = ContactInfo(); + onContactReferenceChanged(&contacts[i], &expanded_contacts[i]); + resetContactValue(contacts[i]); } #else memcpy(expanded_contacts, contacts, sizeof(ContactInfo) * num_contacts); + for (int i = 0; i < num_contacts; ++i) { + onContactReferenceChanged(&contacts[i], &expanded_contacts[i]); + } #endif contacts = expanded_contacts; sort_array = expanded_sort_array; @@ -145,6 +155,7 @@ ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { } if (oldest_idx >= 0) { // NOTE: do NOT call onContactOverwrite() + onContactReferenceChanged(&contacts[oldest_idx], NULL); return &contacts[oldest_idx]; } } else { @@ -169,6 +180,7 @@ ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { if (!onContactOverwrite(contacts[oldest_idx])) { return NULL; } + onContactReferenceChanged(&contacts[oldest_idx], NULL); contact_table_revision++; return &contacts[oldest_idx]; } @@ -178,7 +190,7 @@ ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { } void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp) { - ci = ContactInfo(); + resetContactValue(ci); #if defined(NRF52_PLATFORM) ci.storage_slot = mesh::storage::CONTACT_SLOT_NONE; #endif @@ -228,11 +240,9 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, packet->header = save; } + ContactInfo* transient = NULL; if (from && from->type == ADV_TYPE_NONE) { // already in contacts, but from a temporary ANON_REQ ? - *from = ContactInfo(); // release the anon/temp path as well -#if defined(NRF52_PLATFORM) - from->storage_slot = mesh::storage::CONTACT_SLOT_NONE; -#endif + transient = from; // preserve its references if filters/allocation refuse promotion from = NULL; // do normal 'add' flow } @@ -283,6 +293,7 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, } from->last_advert_timestamp = timestamp; from->lastmod = getRTCClock()->getCurrentTime(); + if (transient != NULL) clearTransientContact(*transient, from); onDiscoveredContact(*from, is_new, packet->path_len, packet->path); // let UI know } @@ -470,7 +481,8 @@ bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_ if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) { // also got an encoded ACK! - if (processAck(extra) != NULL) { + ContactInfo* peer; + if (processAck(extra, peer)) { txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer } } else if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) { @@ -481,11 +493,11 @@ bool BaseChatMesh::onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_ void BaseChatMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { ContactInfo* from; - if ((from = processAck((uint8_t *)&ack_crc)) != NULL) { + if (processAck((uint8_t *)&ack_crc, from)) { txt_send_timeout = 0; // matched one we're waiting for, cancel timeout timer packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit - if (packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) { + if (from != NULL && packet->isRouteFlood() && from->out_path_len != OUT_PATH_UNKNOWN) { // we have direct path, but other node is still sending flood, so maybe they didn't receive reciprocal path properly(?) handleReturnPathRetry(*from, packet->path, packet->path_len); } @@ -932,9 +944,11 @@ void BaseChatMesh::markConnectionActive(const ContactInfo& contact) { } } -ContactInfo* BaseChatMesh::checkConnectionsAck(const uint8_t* data) { +bool BaseChatMesh::checkConnectionsAck(const uint8_t* data, ContactInfo*& peer) { + peer = NULL; for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (connections[i].keep_alive_millis > 0 && memcmp(&connections[i].expected_ack, data, 4) == 0) { + if (connections[i].keep_alive_millis > 0 && connections[i].expected_ack != 0 + && memcmp(&connections[i].expected_ack, data, 4) == 0) { // yes, got an ack for our keep_alive request! connections[i].expected_ack = 0; connections[i].last_activity = getRTCClock()->getCurrentTime(); @@ -943,10 +957,11 @@ ContactInfo* BaseChatMesh::checkConnectionsAck(const uint8_t* data) { connections[i].next_ping = futureMillis(connections[i].keep_alive_millis); auto id = &connections[i].server_id; - return lookupContactByPubKey(id->pub_key, PUB_KEY_SIZE); // yes, a match + peer = lookupContactByPubKey(id->pub_key, PUB_KEY_SIZE); + return true; // owned even if its contact was removed } } - return NULL; /// no match + return false; /// no match } void BaseChatMesh::checkConnections() { @@ -1069,9 +1084,10 @@ bool BaseChatMesh::isTransientContact(const ContactInfo& contact) const { return false; } -bool BaseChatMesh::clearTransientContact(ContactInfo& contact) { +bool BaseChatMesh::clearTransientContact(ContactInfo& contact, ContactInfo* replacement) { if (!isTransientContact(contact)) return false; - contact = ContactInfo(); + onContactReferenceChanged(&contact, replacement); + resetContactValue(contact); #if defined(NRF52_PLATFORM) contact.storage_slot = mesh::storage::CONTACT_SLOT_NONE; #endif @@ -1098,12 +1114,14 @@ bool BaseChatMesh::removeContact(ContactInfo& contact) { if (idx >= num_contacts) return false; // not found // remove from contacts array + onContactReferenceChanged(&contacts[idx], NULL); num_contacts--; while (idx < num_contacts) { contacts[idx] = contacts[idx + 1]; + onContactReferenceChanged(&contacts[idx + 1], &contacts[idx]); idx++; } - contacts[num_contacts] = ContactInfo(); // release the retired tail path + resetContactValue(contacts[num_contacts]); // release the retired tail path contact_table_revision++; return true; // Success } diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 05faffea7..dbeabde90 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -108,8 +108,15 @@ protected: sort_array = sort_array_fallback; contact_capacity = CONTACT_PSRAM_FALLBACK_TOTAL_SLOTS; #endif - contact_table_revision = 0; - resetContacts(); + // No live references exist during base construction. Seed the initially + // empty prefix directly; runtime resets also notify reference owners. +#if defined(NRF52_PLATFORM) + for (int i = 0; i < MAX_ANON_CONTACTS; i++) { + contacts[i].storage_slot = mesh::storage::CONTACT_SLOT_NONE; + } +#endif + num_contacts = MAX_ANON_CONTACTS; + contact_table_revision = 1; #ifdef MAX_GROUP_CHANNELS memset(channels, 0, sizeof(channels)); @@ -122,9 +129,13 @@ protected: void bootstrapRTCfromContacts(); bool initializeContactStorage(); + static void resetContactValue(ContactInfo& contact); void resetContacts() { - for (int i = 0; i < num_contacts; ++i) contacts[i] = ContactInfo(); + for (int i = 0; i < num_contacts; ++i) { + onContactReferenceChanged(&contacts[i], NULL); + resetContactValue(contacts[i]); + } #if defined(NRF52_PLATFORM) for (int i = 0; i < MAX_ANON_CONTACTS; i++) { contacts[i].storage_slot = mesh::storage::CONTACT_SLOT_NONE; @@ -147,8 +158,14 @@ protected: // safely retire its associated persistent state. A refusal must not mutate // the contact or its backing state. virtual bool onContactOverwrite(const ContactInfo&) { return true; }; + // A replacement is already initialized with the same identity. Notify after + // copying it, before the old slot is cleared/reused; NULL retires an identity. + // Subclasses that retain no contact pointers need no relocation work. + virtual void onContactReferenceChanged(const ContactInfo*, ContactInfo*) {} virtual void onDiscoveredContact(ContactInfo& contact, bool is_new, uint8_t path_len, const uint8_t* path) = 0; - virtual ContactInfo* processAck(const uint8_t *data) = 0; + // Ownership does not require a surviving contact. Set peer to NULL when the + // expected ACK belongs to us but its recipient was removed or overwritten. + virtual bool processAck(const uint8_t *data, ContactInfo*& peer) = 0; virtual void onContactPathUpdated(const ContactInfo& contact) = 0; virtual bool onContactPathRecv(ContactInfo& from, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len); virtual void onMessageRecv(const ContactInfo& contact, mesh::Packet* pkt, uint32_t sender_timestamp, const char *text) = 0; @@ -190,7 +207,7 @@ protected: void stopConnection(const uint8_t* pub_key); bool hasConnectionTo(const uint8_t* pub_key); void markConnectionActive(const ContactInfo& contact); - ContactInfo* checkConnectionsAck(const uint8_t* data); + bool checkConnectionsAck(const uint8_t* data, ContactInfo*& peer); void checkConnections(); public: @@ -221,7 +238,8 @@ public: ContactInfo* lookupTransientContactByPubKey(const uint8_t* pub_key, int prefix_len); ContactInfo* lookupPersistentContactByPubKey(const uint8_t* pub_key, int prefix_len); bool isTransientContact(const ContactInfo& contact) const; - bool clearTransientContact(ContactInfo& contact); + // Supply a live same-identity replacement when promoting a transient peer. + bool clearTransientContact(ContactInfo& contact, ContactInfo* replacement = NULL); bool removeContact(ContactInfo& contact); bool addContact(const ContactInfo& contact); int getTotalContactSlots() const { return num_contacts; } diff --git a/test/fixtures/companion_ack_delivery/test.cpp b/test/fixtures/companion_ack_delivery/test.cpp index 398f8df83..928f0e207 100644 --- a/test/fixtures/companion_ack_delivery/test.cpp +++ b/test/fixtures/companion_ack_delivery/test.cpp @@ -72,11 +72,12 @@ public: return retry_active[key[0]]; } void rememberOneKeyAck(ContactInfo&) { ++one_key_notices; } - ContactInfo* checkConnectionsAck(const uint8_t* input) { + bool checkConnectionsAck(const uint8_t* input, ContactInfo*& peer) { ++connection_ack_calls; last_connection_ack_input = input; memcpy(&last_connection_ack, input, sizeof(last_connection_ack)); - return last_connection_ack == connection_ack ? connection_ack_contact : nullptr; + peer = last_connection_ack == connection_ack ? connection_ack_contact : nullptr; + return peer != nullptr; } bool hasTerminalOutput() const { return true; } Terminal& terminalOutput() { return terminal; } @@ -89,7 +90,14 @@ public: void clearExpectedAck(AckTableEntry&, bool = true); void expireExpectedAcks(); AckTableEntry* findPendingTextMessage(const uint8_t*, uint32_t); - ContactInfo* processAck(const uint8_t*); + bool processAck(const uint8_t*, ContactInfo*&); + // Existing delivery scenarios inspect the optional peer; the lifetime suite + // separately exercises ownership with a NULL peer through BaseChatMesh. + ContactInfo* processAck(const uint8_t* input) { + ContactInfo* peer = nullptr; + processAck(input, peer); + return peer; + } void cancelSerialOperationsForRoute(BaseSerialInterface*); bool hasFiniteDelayedReplyForRoute(BaseSerialInterface*) const; void service(); diff --git a/test/fixtures/companion_contact_lifetime/mocks/Identity.h b/test/fixtures/companion_contact_lifetime/mocks/Identity.h new file mode 100644 index 000000000..0c14b9e72 --- /dev/null +++ b/test/fixtures/companion_contact_lifetime/mocks/Identity.h @@ -0,0 +1,2 @@ +#pragma once +#include "../../../../src/Identity.h" diff --git a/test/fixtures/companion_contact_lifetime/mocks/Mesh.h b/test/fixtures/companion_contact_lifetime/mocks/Mesh.h new file mode 100644 index 000000000..ecedb6b96 --- /dev/null +++ b/test/fixtures/companion_contact_lifetime/mocks/Mesh.h @@ -0,0 +1,6 @@ +#pragma once + +// Keep the real identities, packets and contact/path-cache types while the +// source-linked fixture supplies only radio/clock/storage hardware boundaries. +#include +#include diff --git a/test/fixtures/companion_contact_lifetime/mocks/Utils.h b/test/fixtures/companion_contact_lifetime/mocks/Utils.h new file mode 100644 index 000000000..745b877fc --- /dev/null +++ b/test/fixtures/companion_contact_lifetime/mocks/Utils.h @@ -0,0 +1,2 @@ +#pragma once +#include "../../../../src/Utils.h" diff --git a/test/fixtures/companion_contact_lifetime/test.cpp b/test/fixtures/companion_contact_lifetime/test.cpp new file mode 100644 index 000000000..f116a7659 --- /dev/null +++ b/test/fixtures/companion_contact_lifetime/test.cpp @@ -0,0 +1,650 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_ANON_CONTACTS 8 +#define MAX_CONTACTS 20 +#define MAX_CONNECTIONS 16 +#define CONTACT_PSRAM_FALLBACK_TOTAL_SLOTS (MAX_ANON_CONTACTS + 16) +#define ADV_TYPE_NONE 0 +#define ADV_TYPE_CHAT 1 + +#include "production_constants.inc" +#include "production_types.inc" +#include "production_stream.inc" +#include "production_constructors.inc" + +#if MESH_CONTACT_CACHE +namespace mesh { +ContactPathStorage& contactPathStorage() { + static ContactPathPool<128, 16> pool; + return pool; +} +} +#endif + +static unsigned allocations = 0, fail_allocation = 0; +static std::set outstanding_allocations; +static constexpr int MALLOC_CAP_SPIRAM = 1, MALLOC_CAP_8BIT = 2; +static void* heap_caps_calloc(size_t count, size_t size, int) { + ++allocations; + if (allocations == fail_allocation) return nullptr; + void* result = calloc(count, size); + if (result != nullptr) outstanding_allocations.insert(result); + return result; +} +static void heap_caps_free(void* pointer) { + assert(outstanding_allocations.erase(pointer) == 1); + free(pointer); +} + +struct Clock { unsigned long getMillis() const { return millis(); } }; +struct RTC { uint32_t getCurrentTime() const { return 100; } }; +struct Store { + bool delete_ok = true, incomplete = false; + unsigned deletions = 0; + bool deleteBlobByKey(const uint8_t*, size_t) { ++deletions; return delete_ok; } + bool hasIncompleteContactLoad() const { return incomplete; } +}; +struct Terminal { + std::string text; + void print(const char* value) { text += value; } + void printf(const char* format, ...) { + char bytes[160]; + va_list args; + va_start(args, format); + vsnprintf(bytes, sizeof(bytes), format, args); + va_end(args); + text += bytes; + } +}; + +static ContactInfo makeContact(uint8_t key, uint8_t type = ADV_TYPE_CHAT) { + ContactInfo result; + memset(result.id.pub_key, key, sizeof(result.id.pub_key)); + result.type = type; + result.lastmod = key + 100; + result.out_path_len = OUT_PATH_UNKNOWN; + snprintf(result.name, sizeof(result.name), "peer-%u", key); + if (key <= 3) { + const uint8_t path[] = {key}; + assert(result.setPath(path, 1)); + } + return result; +} + +class BaseChatMesh { +public: +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + ContactInfo contacts_fallback[MAX_ANON_CONTACTS + 16]; + int sort_array_fallback[MAX_ANON_CONTACTS + 16] = {}; + ContactInfo* contacts = contacts_fallback; + int* sort_array = sort_array_fallback; + int contact_capacity = MAX_ANON_CONTACTS + 16; +#else + ContactInfo contacts[MAX_ANON_CONTACTS + MAX_CONTACTS]; +#endif + int num_contacts = 0; + uint32_t contact_table_revision = 0; + uint32_t txt_send_timeout = 0; + Clock clock; + Clock* _ms = &clock; + RTC rtc; + ConnectionInfo connections[MAX_CONNECTIONS] = {}; +#ifdef MAX_GROUP_CHANNELS + ChannelDetails channels[MAX_GROUP_CHANNELS]; + int num_channels; +#endif + mesh::Packet* _pendingLoopback = nullptr; + uint8_t temp_buf[MAX_TRANS_UNIT] = {}; + bool auto_add = true, overwrite = true, can_mutate = true; + uint8_t max_hops = 0; + unsigned discoveries = 0, full_notices = 0, path_updates = 0; + std::vector path_notices; + + BaseChatMesh() { +#include "production_base_constructor.inc" + } + virtual ~BaseChatMesh() { +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + if (contacts != contacts_fallback) { + for (int i = 0; i < contact_capacity; ++i) contacts[i].~ContactInfo(); + heap_caps_free(contacts); + heap_caps_free(sort_array); + } +#endif + } + virtual void onContactReferenceChanged(const ContactInfo*, ContactInfo*) {} + virtual bool onContactOverwrite(const ContactInfo&) = 0; + virtual bool processAck(const uint8_t*, ContactInfo*&) = 0; + bool shouldOverwriteWhenFull() const { return overwrite; } + bool shouldAutoAddContactType(uint8_t) const { return auto_add; } + bool canMutateContacts() const { return can_mutate; } + uint8_t getAutoAddMaxHops() const { return max_hops; } + RTC* getRTCClock() { return &rtc; } + bool putBlobByKey(const uint8_t*, int, const uint8_t*, int) { return true; } + void onDiscoveredContact(ContactInfo&, bool, uint8_t, const uint8_t*) { ++discoveries; } + void onContactsFull() { ++full_notices; } + void onContactPathUpdated(const ContactInfo&) { ++path_updates; } + void onContactResponse(const ContactInfo&, const uint8_t*, uint8_t) {} + void handleReturnPathRetry(const ContactInfo& peer, const uint8_t*, uint8_t) { + path_notices.push_back(peer.id.pub_key[0]); + } + void resetContacts(); + static void resetContactValue(ContactInfo&); + bool initializeContactStorage(); + ContactInfo* allocateContactSlot(bool = false); + void populateContactFromAdvert(ContactInfo&, const mesh::Identity&, const AdvertDataParser&, uint32_t); + void onAdvertRecv(mesh::Packet*, const mesh::Identity&, uint32_t, const uint8_t*, size_t); + ContactInfo* lookupContactByPubKey(const uint8_t*, int); + ContactInfo* lookupTransientContactByPubKey(const uint8_t*, int); + ContactInfo* lookupPersistentContactByPubKey(const uint8_t*, int); + bool isTransientContact(const ContactInfo&) const; + bool clearTransientContact(ContactInfo&, ContactInfo* = nullptr); + bool addContact(const ContactInfo&); + bool removeContact(ContactInfo&); + bool checkConnectionsAck(const uint8_t*, ContactInfo*&); + bool onContactPathRecv(ContactInfo&, uint8_t*, uint8_t, uint8_t*, uint8_t, uint8_t, uint8_t*, uint8_t); + void onAckRecv(mesh::Packet*, uint32_t); + bool millisHasNowPassed(unsigned long) const; + unsigned long futureMillis(int) const; +}; + +class MyMesh : public BaseChatMesh { +public: + BaseSerialInterface* _serial = nullptr; + Store store; + Store* _store = &store; + Terminal terminal; + uint8_t out_frame[MAX_FRAME_SIZE] = {}, cmd_frame[MAX_FRAME_SIZE] = {}; +#include "production_entry.inc" + AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE] = {}; + bool has_next_ack_expiry = false; + unsigned long next_ack_expiry = 0; + bool retry_active[256] = {}; + unsigned cancellations = 0, references_changed = 0; + std::vector learned; + bool release_ok = true, restore_ok = true, schedule_ok = true; + unsigned releases = 0, restores = 0, schedules = 0; + unsigned ok_replies = 0, error_replies = 0; + uint8_t last_error = 0; + + bool cancelActiveRetries(const uint8_t* key) { + ++cancellations; retry_active[key[0]] = false; return true; + } + bool hasActiveRetries(const uint8_t* key) const { return retry_active[key[0]]; } + void rememberOneKeyAck(ContactInfo& peer) { learned.push_back(peer.id.pub_key[0]); } + bool hasTerminalOutput() const { return true; } + Terminal& terminalOutput() { return terminal; } + bool scheduleContactWriteAfterRelease(const ContactInfo&, uint16_t& slot) { + ++releases; slot = 7; return release_ok; + } + bool restoreContactWriteAfterRelease(const ContactInfo&, uint16_t) { ++restores; return restore_ok; } + bool scheduleContactWrite(const ContactInfo&) { ++schedules; return schedule_ok; } + void updateGpsTelemetryPolicy() {} + void writeOKFrame() { ++ok_replies; } + void writeErrFrame(uint8_t error) { ++error_replies; last_error = error; } + void onContactReferenceChanged(const ContactInfo* old, ContactInfo* replacement) override { + ++references_changed; + if (replacement != nullptr) assert(old->id.matches(replacement->id)); + applyContactReferenceChanged(old, replacement); + } + void applyContactReferenceChanged(const ContactInfo*, ContactInfo*); + bool onContactOverwrite(const ContactInfo&) override; + bool processAck(const uint8_t*, ContactInfo*&) override; + void clearExpectedAck(AckTableEntry&, bool = true); + void expireExpectedAcks(); + bool updateContactFromFrame(ContactInfo&, uint32_t&, const uint8_t*, int); + void handleContactCommand(int); + void acceptVerifiedOneKey(const mesh::Identity&); + void service(); +}; + +#include "production_functions.inc" + +struct Fixture { + BufferStream usb_stream, other_stream; + ArduinoSerialInterface usb, other; + MultiSerialInterface manager; + MyMesh node; + + Fixture() { + g_mock_millis = 100; + allocations = fail_allocation = 0; + assert(outstanding_allocations.empty()); + assert(node.references_changed == 0); + assert(node.num_contacts == MAX_ANON_CONTACTS && node.contact_table_revision == 1); + assert(node.txt_send_timeout == 0 && node._pendingLoopback == nullptr); + for (const auto& connection : node.connections) { + assert(connection.keep_alive_millis == 0 && connection.expected_ack == 0); + assert(connection.next_ping == 0 && connection.last_activity == 0); + } +#ifdef MAX_GROUP_CHANNELS + assert(node.num_channels == 0); + for (const auto& channel : node.channels) { + for (uint8_t byte : channel.channel.secret) assert(byte == 0); + for (char byte : channel.name) assert(byte == 0); + assert(channel.channel.tx_radio == mesh::RADIO_TX_AUTO); + } +#endif + for (int i = 0; i < MAX_ANON_CONTACTS; ++i) { + assert(node.contacts[i].type == ADV_TYPE_NONE && node.contacts[i].id.pub_key[0] == 0); +#if defined(NRF52_PLATFORM) + assert(node.contacts[i].storage_slot == mesh::storage::CONTACT_SLOT_NONE); +#endif + } + usb.begin(usb_stream); usb.enableFlowControl(true); + other.begin(other_stream); other.enableFlowControl(true); + assert(manager.addInterface(InterfaceType::USB, &usb)); + assert(manager.addInterface(InterfaceType::HardwareSerial, &other)); + manager.enable(); + node._serial = &manager; + } + ContactInfo* add(uint8_t key) { + const ContactInfo contact = makeContact(key); + assert(node.addContact(contact)); + return node.lookupPersistentContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + } + ContactInfo* transient(uint8_t key) { + const ContactInfo contact = makeContact(key, ADV_TYPE_NONE); + assert(node.addContact(contact)); + return node.lookupTransientContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + } + void fill() { +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + const int capacity = node.contact_capacity; +#else + const int capacity = MAX_ANON_CONTACTS + MAX_CONTACTS; +#endif + while (node.num_contacts < capacity) add(uint8_t(node.num_contacts + 10)); + } + MyMesh::AckTableEntry& pending(ContactInfo* peer, unsigned slot = 0, + uint32_t ack = 0x11223344, BaseSerialInterface* route = nullptr) { + auto& entry = node.expected_ack_table[slot]; + entry.ack = ack; entry.msg_sent = 50; entry.expires_at = 10000; + entry.message_timestamp = 1; entry.contact = peer; + entry.reply_route = route == nullptr ? &usb : route; + entry.retry_key[0] = uint8_t(slot + 1); node.retry_active[slot + 1] = true; + entry.text_fingerprint[0] = 0xaa; + return entry; + } + void blockUsb() { + usb_stream.write_capacity = 0; + const uint8_t required = 0; + for (unsigned i = 0; i < 4; ++i) assert(manager.writeFrameToRoute(&usb, &required, 1) == 1); + } + bool ack(uint32_t crc, ContactInfo*& peer) { + return node.processAck(reinterpret_cast(&crc), peer); + } + void received(uint32_t crc) { + mesh::Packet packet; + packet.header = ROUTE_TYPE_FLOOD; + node.txt_send_timeout = 9000; + node.onAckRecv(&packet, crc); + assert(packet.isMarkedDoNotRetransmit() && node.txt_send_timeout == 0); + // Actual Packet marking replaces the route header. Preserve the current + // (separately audited) no-return-path behavior rather than a fake Packet. + assert(node.path_notices.empty()); + } + void drain() { + usb_stream.write_capacity = other_stream.write_capacity = 4096; + for (unsigned i = 0; i < 8; ++i) manager.loop(); + } + void command(uint8_t key, uint8_t code = CMD_ADD_UPDATE_CONTACT) { + memset(node.cmd_frame, 0, sizeof(node.cmd_frame)); + node.cmd_frame[0] = code; + memset(node.cmd_frame + 1, key, PUB_KEY_SIZE); + if (code == CMD_REMOVE_CONTACT) { node.handleContactCommand(1 + PUB_KEY_SIZE); return; } + node.cmd_frame[1 + PUB_KEY_SIZE] = ADV_TYPE_CHAT; + node.cmd_frame[1 + PUB_KEY_SIZE + 2] = OUT_PATH_UNKNOWN; + memcpy(node.cmd_frame + 1 + PUB_KEY_SIZE + 3 + MAX_PATH_SIZE, "updated", 8); + node.handleContactCommand(CONTACT_UPDATE_FRAME_MIN_LEN); + } + void advert(uint8_t key, uint8_t hops = 0) { + ContactInfo contact = makeContact(key); + uint8_t data[MAX_ADVERT_DATA_SIZE]; + const size_t size = AdvertDataBuilder(ADV_TYPE_CHAT, "new-advert").encodeTo(data); + mesh::Packet packet; + packet.header = ROUTE_TYPE_FLOOD; packet.path_len = hops; + node.onAdvertRecv(&packet, contact.id, 100, data, size); + } +}; + +static void unchanged(const MyMesh::AckTableEntry& current, const MyMesh::AckTableEntry& previous) { + assert(current.ack == previous.ack && current.msg_sent == previous.msg_sent); + assert(current.expires_at == previous.expires_at && current.confirmed == previous.confirmed); + assert(current.message_timestamp == previous.message_timestamp && current.reply_route == previous.reply_route); + assert(memcmp(current.retry_key, previous.retry_key, sizeof(current.retry_key)) == 0); + assert(memcmp(current.text_fingerprint, previous.text_fingerprint, sizeof(current.text_fingerprint)) == 0); +#if COMPANION_FEATURE_TEXT_TERMINAL + assert(current.terminal_origin == previous.terminal_origin); +#endif +} +static unsigned confirmations(const std::vector& bytes, uint32_t ack = 0x11223344) { + unsigned count = 0; + size_t offset = 0; + while (offset < bytes.size()) { + assert(offset + 3 <= bytes.size() && bytes[offset] == '>'); + const size_t size = bytes[offset + 1] | (size_t(bytes[offset + 2]) << 8); + offset += 3; + assert(offset + size <= bytes.size()); + if (bytes[offset] == PUSH_CODE_SEND_CONFIRMED) { + uint32_t crc, rtt; + assert(size == 9); + memcpy(&crc, bytes.data() + offset + 1, 4); + memcpy(&rtt, bytes.data() + offset + 5, 4); + if (crc == ack) { assert(rtt == 50); ++count; } + } + offset += size; + } + return count; +} + +int main() { + unsigned checks = 0; + { + Fixture f; + // Execute the real constructor body without a live reference callback, + // then prove a later runtime reset uses the hook and advances revision. + assert(f.node.references_changed == 0 && f.node.contact_table_revision == 1); + f.node.resetContacts(); + assert(f.node.references_changed == MAX_ANON_CONTACTS); + assert(f.node.num_contacts == MAX_ANON_CONTACTS && f.node.contact_table_revision == 2); + ++checks; + } + { + Fixture f; + auto* a = f.add(1); auto* b = f.add(2); auto* c = f.add(3); + auto& first = f.pending(b); auto& second = f.pending(c, 1, 0x55667788); + auto& same_peer = f.pending(b, 2, 0x88776655); + const auto prior = first; + assert(f.node.removeContact(*a)); + assert(first.contact == &f.node.contacts[MAX_ANON_CONTACTS]); + assert(second.contact == &f.node.contacts[MAX_ANON_CONTACTS + 1]); + assert(same_peer.contact == first.contact); + unchanged(first, prior); + assert(first.contact->id.pub_key[0] == 2 && second.contact->id.pub_key[0] == 3); + uint8_t path[MAX_PATH_SIZE]; + assert(first.contact->copyPathTo(path) && path[0] == 2); + assert(second.contact->copyPathTo(path) && path[0] == 3); + f.received(first.ack); f.received(second.ack); f.received(same_peer.ack); + if (MESH_ENABLE_ONE_KEY_DM) assert((f.node.learned == std::vector{2, 3, 2})); + else assert(f.node.learned.empty()); + ++checks; + } + { + Fixture f; auto* peer = f.add(1); f.blockUsb(); + auto& entry = f.pending(peer); const auto prior = entry; + assert(f.node.removeContact(*peer) && entry.contact == nullptr); + unchanged(entry, prior); assert(f.node.retry_active[1]); + f.received(entry.ack); + assert(entry.confirmed && entry.reply_route == &f.usb && entry.msg_sent == 50); + assert(entry.expires_at == 10100 && f.node.cancellations == 1 && f.node.learned.empty()); + f.drain(); g_mock_millis = 110; f.node.service(); f.drain(); + assert(entry.ack == 0 && confirmations(f.usb_stream.output) == 1); + assert(f.other_stream.output.empty()); + ++checks; + } + { + Fixture f; f.add(1); auto* tail = f.add(2); + auto& entry = f.pending(tail); const auto prior = entry; + assert(f.node.removeContact(*tail) && entry.contact == nullptr); + unchanged(entry, prior); + f.add(3); assert(entry.contact == nullptr); + f.received(entry.ack); assert(f.node.learned.empty()); + ++checks; + } + { + Fixture f; auto* peer = f.add(1); auto* anon = f.transient(9); + auto& entry = f.pending(peer); auto& transient_entry = f.pending(anon, 1, 0x55667788); + const auto prior = entry; + f.node.resetContacts(); + assert(entry.contact == nullptr && transient_entry.contact == nullptr); + unchanged(entry, prior); f.add(2); assert(entry.contact == nullptr); + f.received(entry.ack); assert(f.node.learned.empty()); + ++checks; + } + { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + ContactInfo external = makeContact(10); + assert(!f.node.clearTransientContact(external) && entry.contact == anon); + assert(f.node.clearTransientContact(*anon) && entry.contact == nullptr); + unchanged(entry, prior); f.received(entry.ack); + assert(f.node.learned.empty()); + ++checks; + } + { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + // Every unused transient has lastmod zero; force this exact slot to be the + // oldest reusable slot so stale references cannot attach to another key. + for (int i = 0; i < MAX_ANON_CONTACTS; ++i) f.node.contacts[i].lastmod = 1000; + anon->lastmod = 1; + assert(f.transient(10) == anon && entry.contact == nullptr); + unchanged(entry, prior); f.received(entry.ack); assert(f.node.learned.empty()); + ++checks; + } + for (unsigned failure = 0; failure < 2; ++failure) { + Fixture f; auto* peer = f.add(1); f.fill(); + auto& entry = f.pending(peer); const auto prior = entry; + const auto notifications = f.node.references_changed; + if (failure == 0) f.node.release_ok = false; + else f.node.store.delete_ok = false; + assert(!f.node.addContact(makeContact(99))); + assert(entry.contact == peer && peer->id.pub_key[0] == 1); + unchanged(entry, prior); + assert(f.node.references_changed == notifications && f.node.cancellations == 0); + assert(f.node.restores == (failure == 1 ? 1u : 0u)); + ++checks; + } + { + Fixture f; auto* peer = f.add(1); f.fill(); + auto& entry = f.pending(peer); const auto prior = entry; + assert(f.node.addContact(makeContact(99)) && entry.contact == nullptr); + assert(peer->id.pub_key[0] == 99); unchanged(entry, prior); + f.received(entry.ack); assert(f.node.learned.empty()); + ++checks; + } + for (unsigned failure = 0; failure < 2; ++failure) { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + const auto prior_contact = *anon; + if (failure == 0) { f.fill(); f.node.overwrite = false; } + else f.node.schedule_ok = false; + f.command(9); + assert(f.node.error_replies == 1 && f.node.ok_replies == 0); + assert(entry.contact == anon && anon->id.matches(prior_contact.id)); + assert(anon->lastmod == prior_contact.lastmod && anon->type == prior_contact.type); + unchanged(entry, prior); assert(f.node.cancellations == 0); + ++checks; + } + { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + f.command(9); + const ContactInfo expected = makeContact(9); + auto* accepted = f.node.lookupPersistentContactByPubKey(expected.id.pub_key, PUB_KEY_SIZE); + assert(accepted != nullptr && entry.contact == accepted && anon->id.pub_key[0] == 0); + assert(f.node.ok_replies == 1 && f.node.error_replies == 0); + unchanged(entry, prior); f.received(entry.ack); + if (MESH_ENABLE_ONE_KEY_DM) assert((f.node.learned == std::vector{9})); + ++checks; + } + for (unsigned failure = 0; failure < 2; ++failure) { + Fixture f; auto* persistent = f.add(9); auto* anon = f.transient(9); + auto& entry = f.pending(anon); const auto prior = entry; + f.node.schedule_ok = failure == 0; + f.command(9); + assert(entry.contact == (failure == 0 ? persistent : anon)); + assert(f.node.ok_replies == (failure == 0 ? 1u : 0u)); + assert(f.node.error_replies == (failure == 1 ? 1u : 0u)); + unchanged(entry, prior); + ++checks; + } + { + Fixture f; auto* old = f.add(1); auto* next = f.add(2); auto* anon = f.transient(9); f.fill(); + auto& old_entry = f.pending(old); + auto& next_entry = f.pending(next, 1, 0x55667788); + auto& anon_entry = f.pending(anon, 2, 0x88776655); + const auto old_state = old_entry, next_state = next_entry, anon_state = anon_entry; + f.node.schedule_ok = false; f.command(9); + assert(f.node.error_replies == 1 && old_entry.contact == nullptr); + assert(next_entry.contact == &f.node.contacts[MAX_ANON_CONTACTS]); + assert(next_entry.contact->id.pub_key[0] == 2 && anon_entry.contact == anon); + unchanged(old_entry, old_state); unchanged(next_entry, next_state); unchanged(anon_entry, anon_state); + assert(f.node.cancellations == 0); + f.received(old_entry.ack); assert(f.node.learned.empty()); + ++checks; + } + for (unsigned refusal = 0; refusal < 3; ++refusal) { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + if (refusal == 0) f.node.auto_add = false; + if (refusal == 1) f.node.max_hops = 1; + if (refusal == 2) { f.fill(); f.node.overwrite = false; } + f.advert(9, refusal == 1 ? 1 : 0); + assert(entry.contact == anon && anon->id.pub_key[0] == 9); + unchanged(entry, prior); assert(f.node.cancellations == 0); + ++checks; + } + { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + f.advert(9); + const ContactInfo identity = makeContact(9); + auto* accepted = f.node.lookupPersistentContactByPubKey(identity.id.pub_key, PUB_KEY_SIZE); + assert(accepted != nullptr && entry.contact == accepted && anon->id.pub_key[0] == 0); + unchanged(entry, prior); f.received(entry.ack); + ++checks; + } + for (unsigned failure = 0; failure < 2; ++failure) { + Fixture f; auto* anon = f.transient(9); auto& entry = f.pending(anon); + const auto prior = entry; + f.node.schedule_ok = failure == 0; + const auto identity = makeContact(9).id; + f.node.acceptVerifiedOneKey(identity); + if (failure == 0) { + assert(entry.contact == f.node.lookupPersistentContactByPubKey(identity.pub_key, PUB_KEY_SIZE)); + assert(anon->id.pub_key[0] == 0); + } else assert(entry.contact == anon && anon->id.pub_key[0] == 9); + unchanged(entry, prior); + ++checks; + } + for (unsigned failure = 0; failure < 2; ++failure) { + Fixture f; auto* peer = f.add(1); auto& entry = f.pending(peer); + const auto prior = entry; + if (failure == 0) f.node.release_ok = false; + else f.node.store.delete_ok = false; + f.command(1, CMD_REMOVE_CONTACT); + assert(f.node.error_replies == 1 && entry.contact == peer && peer->id.pub_key[0] == 1); + unchanged(entry, prior); assert(f.node.cancellations == 0); + ++checks; + } +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + for (unsigned failure = 1; failure <= 2; ++failure) { + Fixture f; auto* peer = f.add(1); auto& entry = f.pending(peer); + const auto prior = entry; + const auto notifications = f.node.references_changed; + fail_allocation = failure; + assert(!f.node.initializeContactStorage()); + assert(entry.contact == peer && f.node.contacts == f.node.contacts_fallback); + assert(outstanding_allocations.empty() && f.node.references_changed == notifications); + unchanged(entry, prior); + ++checks; + } + { + Fixture f; auto* peer = f.add(1); auto& entry = f.pending(peer); + entry.confirmed = true; entry.msg_sent = 50; entry.expires_at = 10100; + const auto prior = entry; + assert(f.node.initializeContactStorage()); + assert(entry.contact != peer && entry.contact == &f.node.contacts[MAX_ANON_CONTACTS]); + assert(entry.contact->id.pub_key[0] == 1); + uint8_t path[MAX_PATH_SIZE]; assert(entry.contact->copyPathTo(path) && path[0] == 1); + unchanged(entry, prior); assert(f.node.cancellations == 0); + ++checks; + } +#endif + { + Fixture f; auto* peer = f.add(1); + auto& connection = f.node.connections[0]; + connection.server_id = peer->id; connection.keep_alive_millis = 1000; + connection.last_activity = 1; connection.next_ping = 2; + ContactInfo* result = peer; + assert(!f.ack(0, result) && result == nullptr); + assert(connection.last_activity == 1 && connection.next_ping == 2); + connection.expected_ack = 0x11223344; + assert(f.ack(connection.expected_ack, result) && result == peer); + assert(connection.expected_ack == 0 && connection.last_activity == 100); + result = peer; assert(!f.ack(0x11223344, result) && result == nullptr); + connection.expected_ack = 0x11223344; + assert(f.node.removeContact(*peer)); + f.received(connection.expected_ack); + assert(connection.expected_ack == 0 && f.node.learned.empty()); + connection.expected_ack = 0x11223344; connection.keep_alive_millis = 0; + assert(!f.ack(0x11223344, result) && result == nullptr); + ++checks; + } + { + Fixture f; auto* peer = f.add(1); auto& entry = f.pending(peer); + assert(f.node.removeContact(*peer)); + ContactInfo sender = makeContact(2); uint8_t path[] = {3}; + uint32_t crc = entry.ack; + f.node.can_mutate = false; f.node.txt_send_timeout = 9000; + assert(f.node.onContactPathRecv(sender, path, 1, path, 1, PAYLOAD_TYPE_ACK, + reinterpret_cast(&crc), sizeof(crc))); + assert(f.node.txt_send_timeout == 0 && f.node.cancellations == 1 && f.node.learned.empty()); + ++checks; + } + { + Fixture f; auto* a = f.add(1); auto* b = f.add(2); f.blockUsb(); + auto& entry = f.pending(b); + ContactInfo* result = nullptr; + assert(f.ack(entry.ack, result) && result == b && entry.confirmed); + const auto prior = entry; + const auto learned = f.node.learned; + assert(f.node.removeContact(*a)); + assert(entry.contact == &f.node.contacts[MAX_ANON_CONTACTS]); unchanged(entry, prior); + assert(!f.ack(entry.ack, result) && result == nullptr); + assert(f.node.removeContact(*entry.contact) && entry.contact == nullptr); + unchanged(entry, prior); assert(f.node.learned == learned && f.node.cancellations == 1); + f.drain(); g_mock_millis = 110; f.node.service(); f.drain(); + assert(entry.ack == 0 && confirmations(f.usb_stream.output) == 1); + ++checks; + } + { + Fixture f; auto* old = f.add(1); auto* live = f.add(2); f.blockUsb(); + auto& first = f.pending(old); + auto& second = f.pending(live, 1, first.ack, &f.other); + assert(f.node.removeContact(*old) && first.contact == nullptr && second.contact->id.pub_key[0] == 2); + ContactInfo* result = live; + assert(f.ack(first.ack, result) && result == nullptr && first.confirmed && !second.confirmed); + assert(f.ack(first.ack, result) && result == &f.node.contacts[MAX_ANON_CONTACTS]); + assert(f.node.cancellations == 2); + if (MESH_ENABLE_ONE_KEY_DM) assert((f.node.learned == std::vector{2})); + else assert(f.node.learned.empty()); + ++checks; + } +#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) + assert(checks == 31); +#else + assert(checks == 28); +#endif + assert(outstanding_allocations.empty()); + printf("PASS: %u production contact lifetime checks\n", checks); +} diff --git a/test/test_companion_ack_delivery.py b/test/test_companion_ack_delivery.py index 1a3cdec21..bde7ac8fe 100644 --- a/test/test_companion_ack_delivery.py +++ b/test/test_companion_ack_delivery.py @@ -31,7 +31,7 @@ class CompanionAckDeliveryTests(unittest.TestCase): functions = "\n".join(extract_braced(source, signature) for signature in ( "void MyMesh::clearExpectedAck(", "void MyMesh::expireExpectedAcks(", "MyMesh::AckTableEntry* MyMesh::findPendingTextMessage(", - "ContactInfo* MyMesh::processAck(", + "bool MyMesh::processAck(", "void MyMesh::cancelSerialOperationsForRoute(", "bool MyMesh::hasFiniteDelayedReplyForRoute(", )) diff --git a/test/test_companion_contact_lifetime.py b/test/test_companion_contact_lifetime.py new file mode 100644 index 000000000..e12ed7418 --- /dev/null +++ b/test/test_companion_contact_lifetime.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Execute production contact mutations and ACK ownership under sanitizers.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/companion_contact_lifetime" +SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + + +class CompanionContactLifetimeTests(unittest.TestCase): + def test_production_mutations_and_owned_ack(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + header = (ROOT / "examples/companion_radio/MyMesh.h").read_text() + base = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text() + base_header = (ROOT / "src/helpers/BaseChatMesh.h").read_text() + constructor = extract_braced(base_header, "BaseChatMesh(mesh::Radio&") + constructor_body = constructor[constructor.index("{") + 1:-1] + packet = (ROOT / "src/Packet.cpp").read_text() + identity = (ROOT / "src/Identity.cpp").read_text() + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() + native = (ROOT / "test/test_serial_mode_switch/test_serial_mode_switch.cpp").read_text() + functions = "\n".join(extract_braced(base, signature) for signature in ( + "void BaseChatMesh::resetContactValue(", + "bool BaseChatMesh::initializeContactStorage(", + "void BaseChatMesh::populateContactFromAdvert(", + "void BaseChatMesh::onAdvertRecv(", + "ContactInfo* BaseChatMesh::lookupContactByPubKey(", + "ContactInfo* BaseChatMesh::lookupTransientContactByPubKey(", + "ContactInfo* BaseChatMesh::lookupPersistentContactByPubKey(", + "bool BaseChatMesh::isTransientContact(", + "bool BaseChatMesh::clearTransientContact(", + "bool BaseChatMesh::addContact(", + "bool BaseChatMesh::removeContact(", + "bool BaseChatMesh::checkConnectionsAck(", + "bool BaseChatMesh::onContactPathRecv(", + "void BaseChatMesh::onAckRecv(", + )) + # Both preprocessor alternatives contain the same opening brace. Keep + # the complete real definition, rather than selecting a copied branch. + functions += "\n" + base[base.index("ContactInfo* BaseChatMesh::allocateContactSlot("): + base.index("void BaseChatMesh::populateContactFromAdvert(")] + functions += "\nvoid BaseChatMesh::" + extract_braced(base_header, "void resetContacts() {")[5:] + functions += "\n" + extract_braced( + (ROOT / "src/helpers/TxtDataHelpers.cpp").read_text(), "void StrHelper::strncpy(") + functions += "\n" + "\n".join(extract_braced(companion, signature) for signature in ( + "void MyMesh::clearExpectedAck(", "void MyMesh::expireExpectedAcks(", + "bool MyMesh::processAck(", "bool MyMesh::onContactOverwrite(", + "bool MyMesh::updateContactFromFrame(", + )) + functions += "\n" + extract_braced(companion, "void MyMesh::onContactReferenceChanged(").replace( + "MyMesh::onContactReferenceChanged(", "MyMesh::applyContactReferenceChanged(", 1) + functions += "\n" + "\n".join(extract_braced(dispatcher, signature).replace( + "Dispatcher::", "BaseChatMesh::") for signature in ( + "bool Dispatcher::millisHasNowPassed(", "unsigned long Dispatcher::futureMillis(")) + functions += "\nvoid MyMesh::service() {\n" + extract_braced(companion, "if (has_next_ack_expiry\n") + "\n}\n" + # Execute the actual admin branches. Framing/authentication is already + # covered separately; the real parser and mutation/rollback are retained. + functions += "\nvoid MyMesh::handleContactCommand(int len) { if (false) {}\n" + functions += extract_braced(companion, "else if (cmd_frame[0] == CMD_ADD_UPDATE_CONTACT)") + functions += "\n" + extract_braced(companion, "else if (cmd_frame[0] == CMD_REMOVE_CONTACT &&") + "\n}\n" + verified = extract_braced(companion, "void MyMesh::onAnonDataRecv(") + tail_start = verified.index(" if (!canMutateContacts()) return;") + tail_end = verified.rindex("\n}") + functions += "\nvoid MyMesh::acceptVerifiedOneKey(const mesh::Identity& sender) {\n" + functions += " ContactInfo contact = makeContact(sender.pub_key[0]);\n" + functions += verified[tail_start:tail_end] + "\n}\n" + constants = "\n".join(re.findall( + r"^#define (?:EXPECTED_ACK_[A-Z_]+|PUSH_CODE_SEND_CONFIRMED|PUSH_CODE_CONTACT_DELETED|" + r"CMD_ADD_UPDATE_CONTACT|CMD_REMOVE_CONTACT|ERR_CODE_[A-Z_]+)\s+.+$", companion, re.MULTILINE)) + constants += "\n#define EXPECTED_ACK_TABLE_SIZE 8\n" + first = companion.index("static constexpr int CONTACT_UPDATE_FRAME_MIN_LEN") + last = companion.index("bool MyMesh::updateContactFromFrame(", first) + constants += "\n" + companion[first:last] + constructors = "namespace mesh {\n" + "\n".join( + extract_braced(identity, signature) for signature in ( + "Identity::Identity()", "LocalIdentity::LocalIdentity()")) + "\n" + constructors += "\n".join(extract_braced(packet, signature) for signature in ( + "Packet::Packet()", "bool Packet::isValidPathLen(", + "size_t Packet::writePath(", "uint8_t Packet::copyPath(", + "uint8_t Packet::writeTo(")) + "\n}\n" + variants = (("generic", 0, 0, 0), ("generic", 1, 1, 1), + ("nrf52", 0, 1, 0), ("nrf52", 1, 1, 1), + ("psram", 0, 0, 0), ("psram", 0, 1, 1), + ("psram", 1, 0, 1), ("psram", 1, 1, 0)) + with tempfile.TemporaryDirectory(prefix="meshcore-contact-lifetime-") as directory: + work = Path(directory) + (work / "production_constants.inc").write_text(constants, encoding="ascii") + (work / "production_types.inc").write_text( + "namespace mesh {\n" + extract_braced((ROOT / "src/Mesh.h").read_text(), "class GroupChannel") + + ";\n}\n" + extract_braced((ROOT / "src/helpers/ChannelDetails.h").read_text(), "struct ChannelDetails") + + ";\n" + extract_braced(base_header, "struct ConnectionInfo") + ";\n", encoding="ascii") + (work / "production_entry.inc").write_text( + extract_braced(header, "struct AckTableEntry") + ";\n", encoding="ascii") + (work / "production_stream.inc").write_text( + extract_braced(native, "class BufferStream") + ";\n", encoding="ascii") + (work / "production_functions.inc").write_text(functions, encoding="ascii") + (work / "production_constructors.inc").write_text(constructors, encoding="ascii") + (work / "production_base_constructor.inc").write_text(constructor_body, encoding="ascii") + for platform, cache, terminal, one_key in variants: + with self.subTest(platform=platform, cache=cache, terminal=terminal, one_key=one_key): + defines = [f"-DMESH_CONTACT_CACHE={cache}", + f"-DCOMPANION_FEATURE_TEXT_TERMINAL={terminal}", + f"-DMESH_ENABLE_ONE_KEY_DM={one_key}"] + if terminal: + defines.append("-DMAX_GROUP_CHANNELS=4") + if platform == "nrf52": + defines.append("-DNRF52_PLATFORM=1") + elif platform == "psram": + defines += ["-DESP32_PLATFORM=1", "-DBOARD_HAS_PSRAM=1"] + binary = work / f"lifetime-{platform}-{cache}-{terminal}-{one_key}.exe" + compiled = subprocess.run([ + compiler, "-std=c++17", "-Werror", *SANITIZERS, *defines, + f"-I{work}", f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'test/mocks'}", + f"-I{ROOT / 'src'}", str(FIXTURE / "test.cpp"), + str(ROOT / "src/helpers/ArduinoSerialInterface.cpp"), + str(ROOT / "src/helpers/AdvertDataHelpers.cpp"), + "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + expected_checks = 31 if platform == "psram" else 28 + self.assertIn(f"PASS: {expected_checks} production contact lifetime checks", checked.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_contact_persistence_contract.py b/test/test_companion_contact_persistence_contract.py index 0e347cf2e..8c36e348d 100644 --- a/test/test_companion_contact_persistence_contract.py +++ b/test/test_companion_contact_persistence_contract.py @@ -152,7 +152,7 @@ class CompanionContactPersistenceContractTests(unittest.TestCase): ) error = section.index("writeErrFrame(ERR_CODE_FILE_IO_ERROR)", rollback) ok = section.index("writeOKFrame()", schedule) - clear_transient = section.index("clearTransientContact(*transient)", schedule) + clear_transient = section.index("clearTransientContact(*transient, recipient)", schedule) self.assertLess(snapshot, mutation) self.assertLess(mutation, schedule) self.assertLess(schedule, clear_transient) @@ -227,10 +227,11 @@ class CompanionContactPersistenceContractTests(unittest.TestCase): self.assertLess(update_ok, update_rollback) self.assertLess(update_rollback, update_error) - promotion = section.index("Promotion must free the exact transient prefix slot") + promotion = section.index("Preserve the transient and its pending ACK references") add_section = section[promotion:] - clear = add_section.index("clearTransientContact(*transient)") add = add_section.index("addContact(candidate)") + schedule = add_section.index("added != NULL && scheduleContactWrite(*added)") + clear = add_section.index("clearTransientContact(*transient, added)") add_schedule = section.index( "added != NULL && scheduleContactWrite(*added)", update_error ) @@ -239,11 +240,13 @@ class CompanionContactPersistenceContractTests(unittest.TestCase): add_error = section.index( "writeErrFrame(ERR_CODE_FILE_IO_ERROR)", add_rollback ) - self.assertLess(clear, add) + self.assertLess(add, schedule) + self.assertLess(schedule, clear) self.assertLess(add_schedule, add_ok) self.assertLess(add_ok, add_rollback) self.assertLess(add_rollback, add_error) - self.assertIn("if (transient != NULL) *transient = previous_transient", add_section) + self.assertNotIn("clearTransientContact", add_section[:schedule]) + self.assertNotIn("previous_transient", section) def test_transient_prefix_is_never_compacted(self): transient_lookup = function_body( diff --git a/test/test_contact_import/test_contact_import.cpp b/test/test_contact_import/test_contact_import.cpp index c92e5ac23..c5013f643 100644 --- a/test/test_contact_import/test_contact_import.cpp +++ b/test/test_contact_import/test_contact_import.cpp @@ -73,7 +73,7 @@ public: void onDiscoveredContact(ContactInfo&, bool, uint8_t, const uint8_t*) override { ++discovered; } - ContactInfo* processAck(const uint8_t*) override { return nullptr; } + bool processAck(const uint8_t*, ContactInfo*& peer) override { peer = nullptr; return false; } void onContactPathUpdated(const ContactInfo&) override {} void onMessageRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {} void onCommandDataRecv(const ContactInfo&, mesh::Packet*, uint32_t, const char*) override {}