Keep MQTT and WebConfig stable under internal memory pressure

This commit is contained in:
mikecarper committed 2026-10-27 11:28:47 -07:00
1 parent 2be068cafa
commit 2fc2f409d4
22 files changed
+1494 -406

No files matched your search

+1
View File
@@ -318,6 +318,7 @@ struct DynamicJsonDocument {
std::string state;
unsigned networks = 0;
explicit DynamicJsonDocument(unsigned) {}
bool overflowed() const { return false; }
JsonValue operator[](const char*) { return {&state}; }
JsonArray createNestedArray(const char*) { return {&networks}; }
};
+38 -41
View File
@@ -221,55 +221,52 @@ int main() {
#include <cstring>
#define ESP_PLATFORM 1
#define BOARD_HAS_PSRAM 1
constexpr uint32_t MALLOC_CAP_INTERNAL = 1, MALLOC_CAP_SPIRAM = 4, MALLOC_CAP_8BIT = 8;
static bool psram_fails = false, internal_fails = false;
static size_t malloc_calls = 0, realloc_calls = 0, free_calls = 0;
constexpr uint32_t MALLOC_CAP_INTERNAL=1, MALLOC_CAP_DMA=2,
MALLOC_CAP_SPIRAM=4, MALLOC_CAP_8BIT=8;
static bool psram_fails=false, internal_fails=false;
static size_t dma_free=100000,dma_largest=100000,internal_calls=0,free_calls=0,malloc_calls=0;
static unsigned char original[32], psram_storage[64], internal_storage[64];
static void* freed = nullptr;
void* allocation(uint32_t caps) {
if (caps == MALLOC_CAP_SPIRAM) return psram_fails ? nullptr : psram_storage;
// INTERNAL alone can select 32-bit-only IRAM on ESP32-S3.
assert(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
return internal_fails ? nullptr : internal_storage;
static void* freed=nullptr;
size_t heap_caps_get_free_size(uint32_t caps){assert(caps==3);return dma_free;}
size_t heap_caps_get_largest_free_block(uint32_t caps){assert(caps==3);return dma_largest;}
size_t heap_caps_get_allocated_size(void*ptr){assert(ptr==original);return sizeof(original);}
void* heap_caps_malloc(size_t size,uint32_t caps){
assert(size==32||size==48);++malloc_calls;
if(caps==MALLOC_CAP_SPIRAM)return psram_fails?nullptr:psram_storage;
assert(caps==(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
++internal_calls;return internal_fails?nullptr:internal_storage;
}
void* heap_caps_malloc(size_t size, uint32_t caps) {
assert(size == 32);
++malloc_calls;
return allocation(caps);
}
void* heap_caps_realloc(void* ptr, size_t size, uint32_t caps) {
assert(ptr == original && size == 48);
++realloc_calls;
return allocation(caps);
}
void heap_caps_free(void* ptr) { ++free_calls; freed = ptr; }
void heap_caps_free(void*ptr){++free_calls;freed=ptr;}
'''
for signature in ("static void* psram_malloc(size_t size)",
"static void psram_free(void* ptr)",
"static void* psram_realloc(void* ptr, size_t new_size)"):
source += method("src/helpers/bridges/MQTTBridge.cpp", signature)
self.compile_run(source + r'''
int main() {
assert(psram_malloc(0) == nullptr && malloc_calls == 0);
assert(psram_malloc(32) == psram_storage && malloc_calls == 1);
psram_fails = true;
assert(psram_malloc(32) == internal_storage && malloc_calls == 3);
internal_fails = true;
assert(psram_malloc(32) == nullptr && malloc_calls == 5 && free_calls == 0);
memset(original, 0xa5, sizeof(original));
psram_fails = internal_fails = false;
assert(psram_realloc(original, 48) == psram_storage && realloc_calls == 1);
psram_fails = true;
assert(psram_realloc(original, 48) == internal_storage && realloc_calls == 3);
internal_fails = true;
assert(psram_realloc(original, 48) == nullptr && realloc_calls == 5);
assert(free_calls == 0);
for (unsigned char byte : original) assert(byte == 0xa5);
assert(psram_realloc(original, 0) == nullptr);
assert(free_calls == 1 && freed == original && realloc_calls == 5);
assert(psram_realloc(nullptr, 0) == nullptr && free_calls == 1);
int main(){
assert(psram_malloc(0)==nullptr&&malloc_calls==0);
assert(psram_malloc(32)==psram_storage&&internal_calls==0);
psram_fails=true;dma_free=16384+31;
assert(psram_malloc(32)==nullptr&&internal_calls==0);
dma_free=16384+32;dma_largest=31;
assert(psram_malloc(32)==nullptr&&internal_calls==0);
dma_largest=32;assert(psram_malloc(32)==internal_storage&&internal_calls==1);
memset(original,0xa5,sizeof(original));
const auto before=malloc_calls;
dma_free=0;assert(psram_realloc(original,16)==original&&malloc_calls==before);
assert(psram_realloc(original,48)==nullptr&&free_calls==0);
for(unsigned char byte:original)assert(byte==0xa5);
psram_fails=false;
assert(psram_realloc(original,48)==psram_storage&&freed==original&&free_calls==1);
assert(!memcmp(original,psram_storage,sizeof(original)));
psram_fails=true;dma_free=16384+48;dma_largest=48;
assert(psram_realloc(original,48)==internal_storage&&free_calls==2);
assert(!memcmp(original,internal_storage,sizeof(original)));
internal_fails=true;
assert(psram_realloc(original,48)==nullptr&&free_calls==2);
for(unsigned char byte:original)assert(byte==0xa5);
assert(psram_realloc(original,0)==nullptr&&free_calls==3);
assert(psram_realloc(nullptr,0)==nullptr&&free_calls==3);
}
''')
+16 -4
View File
@@ -26,9 +26,11 @@ PREAMBLE = r'''
#include <vector>
#include "helpers/AlertFaultPolicy.h"
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTPublishAdmission.h"
#include "helpers/MQTTConnectionHealth.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTTransportHealth.h"
#include "helpers/MQTTStatusPublishPolicy.h"
#include "helpers/WiFiPowerSave.h"
#define ESP_PLATFORM 1
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
@@ -158,6 +160,7 @@ struct PsychicMqttClient {
dma_free += 44000; dma_largest = 20000;
}
void setCredentials(const char*, const char*) { ++credentials; }
void serviceOutboxDiagnostics() {}
};
struct MQTTBridge {
struct MQTTSlot {
@@ -179,8 +182,10 @@ struct MQTTBridge {
int last_sock_errno = 0;
unsigned long last_error_time = 0;
MQTTTransportHealth::State transport_health;
MQTTStatusPublishPolicy::State status_publish;
} _slots[RUNTIME_MQTT_SLOTS];
PsychicMqttClient clients[RUNTIME_MQTT_SLOTS];
MQTTPublishAdmission::State _publish_memory;
char tokens[RUNTIME_MQTT_SLOTS][16] = {};
bool setup_succeeds[RUNTIME_MQTT_SLOTS] = {true, true, true, true, true, true};
bool ready[RUNTIME_MQTT_SLOTS] = {true, true, true, true, true, true};
@@ -191,7 +196,7 @@ struct MQTTBridge {
std::atomic<uint8_t> _slot_health_reason[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_recovering[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint32_t> _slot_health_ack_wait_secs[RUNTIME_MQTT_SLOTS] = {};
bool _status_publish_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _status_publish_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _stop_requested{false}, _ntp_synced{true};
bool _slot_force_jwt_mint[RUNTIME_MQTT_SLOTS] = {};
void* _identity = this;
@@ -206,7 +211,7 @@ struct MQTTBridge {
float *freq = nullptr, *bw = nullptr;
int *sf = nullptr, *cr = nullptr;
} _node_info;
struct Observer { uint8_t wifi_power_save = 1; } observer;
struct Observer { uint8_t wifi_power_save = 1; bool mqtt_status_enabled = true; } observer;
Observer* _obs = &observer;
AlertFaultPolicy::OutageSnapshot _wifi_outage{};
unsigned long _last_slot_reconnect_ms = 0;
@@ -217,6 +222,8 @@ struct MQTTBridge {
const char* _jwt_username = "jwt";
static const unsigned long SLOT_SETUP_RETRY_INTERVAL = 60000;
static const unsigned long SLOT_LOG_INTERVAL = 60000;
static const unsigned long STATUS_RETRY_INTERVAL = 30000;
unsigned long _status_interval = 300000;
static constexpr int STATUS_JSON_BUFFER_SIZE = 1024, MSG_STATUS = 0;
const char *_timezone = "UTC", *_origin = "node", *_device_id = "id";
const char *_board_model = "V4", *_firmware_version = "test";
@@ -276,7 +283,10 @@ struct MQTTBridge {
void updateCachedConnectionStatus() {}
bool ensureSlotClient(int index);
bool publishToSlot(int, const char*, const char*, size_t, bool, uint8_t);
void publishStatusToSlot(int);
bool hasPublishHeadroom(size_t = 0, size_t = 0, uint8_t = 0) { return true; }
bool publishStatusToSlot(int);
bool publishStatus(uint8_t);
void maintainStatus(uint32_t);
void only(int index) {
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) _slots[i].enabled = i == index;
}
@@ -310,7 +320,9 @@ class MqttHandshakeSchedulerTest(unittest.TestCase):
"void MQTTBridge::updateSlotTransportDiag(",
"bool MQTTBridge::ensureSlotClient(",
"bool MQTTBridge::publishToSlot(",
"void MQTTBridge::publishStatusToSlot(",
"bool MQTTBridge::publishStatusToSlot(",
"bool MQTTBridge::publishStatus(",
"void MQTTBridge::maintainStatus(",
"unsigned long MQTTBridge::slotTokenLifetime(int index) const",
"bool MQTTBridge::handleWiFiConnection(unsigned long now)",
"void MQTTBridge::maintainSlotConnections()",
+11
View File
@@ -20,6 +20,7 @@ PREAMBLE = r'''
#include <cassert>
#include <cstddef>
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTPublishAdmission.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTTransportHealth.h"
struct esp_mqtt_client_config_t {
@@ -98,12 +99,19 @@ int main() {
RECONFIGURE_PREAMBLE = r'''
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTPublishAdmission.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTTransportHealth.h"
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
#define pdMS_TO_TICKS(value) (value)
constexpr int RUNTIME_MQTT_SLOTS = 2, MQTT_AUTH_JWT = 1;
constexpr uint32_t MALLOC_CAP_INTERNAL = 1, MALLOC_CAP_DMA = 2;
constexpr uint32_t MALLOC_CAP_SPIRAM = 4, MALLOC_CAP_8BIT = 8;
void* heap_caps_malloc(size_t bytes, uint32_t caps) {
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
return std::malloc(bytes);
}
void heap_caps_free(void* memory) { std::free(memory); }
static size_t dma_free = 100000, dma_largest = 20000;
size_t heap_caps_get_free_size(uint32_t caps) {
assert(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); return dma_free;
@@ -329,6 +337,9 @@ class MqttNetworkTimeoutTest(unittest.TestCase):
"void PsychicMqttClient::_onDisconnect(",
"void PsychicMqttClient::_onSubscribe(",
"void PsychicMqttClient::_onUnsubscribe(",
"size_t PsychicMqttClient::getOutboxSize()",
"size_t PsychicMqttClient::refreshOutboxSize()",
"void PsychicMqttClient::serviceOutboxDiagnostics()",
"void PsychicMqttClient::_onPublish(",
))
source += "\n".join(method("src/helpers/bridges/MQTTBridge.cpp", signature)
+76 -2
View File
@@ -94,6 +94,8 @@ unsigned long millis() { return now_ms; }
void vTaskDelay(unsigned) { assert(false && "stub disconnect must deliver its event"); }
const char* esp_err_to_name(int) { return "mock"; }
static int publish_result = 0, enqueue_result = 41, outbox_size = 0;
static int outbox_queries = 0, enqueue_added_size = 0, stop_result = ESP_OK;
static bool sdk_mutex_blocked = false, sdk_callback_owner = false;
static int publish_calls = 0, enqueue_calls = 0, config_calls = 0;
static int start_calls = 0, reconnect_calls = 0, receive_events = 0, error_events = 0;
static int init_calls = 0, destroy_calls = 0, stop_calls = 0;
@@ -124,7 +126,7 @@ int esp_mqtt_set_config(void*, esp_mqtt_client_config_t*) {
}
int esp_mqtt_client_start(void*) { ++start_calls; return start_result; }
int esp_mqtt_client_reconnect(void*) { ++reconnect_calls; return ESP_OK; }
int esp_mqtt_client_stop(void*) { ++stop_calls; return ESP_OK; }
int esp_mqtt_client_stop(void*) { ++stop_calls; if(stop_result==ESP_OK)outbox_size=0;return stop_result; }
int esp_mqtt_client_disconnect(void*) {
assert(sdk_event_handler != nullptr);
esp_mqtt_event_t event{MQTT_EVENT_DISCONNECTED, 0, false};
@@ -132,7 +134,10 @@ int esp_mqtt_client_disconnect(void*) {
return ESP_OK;
}
int esp_mqtt_client_subscribe(void*, const char*, int) { return 7; }
int esp_mqtt_client_get_outbox_size(void*) { return outbox_size; }
int esp_mqtt_client_get_outbox_size(void*) {
assert(!sdk_mutex_blocked || sdk_callback_owner);
++outbox_queries;return outbox_size;
}
int esp_mqtt_client_publish(void*, const char*, const char*, int, int, bool) {
++publish_calls;
return publish_result;
@@ -140,6 +145,7 @@ int esp_mqtt_client_publish(void*, const char*, const char*, int, int, bool) {
int esp_mqtt_client_enqueue(void*, const char*, const char*, int, int, bool, bool store) {
assert(store);
++enqueue_calls;
if(enqueue_result>=0)outbox_size+=enqueue_added_size;
return enqueue_result;
}
void PsychicMqttClient::_onMessage(esp_mqtt_event_handle_t&) { ++receive_events; }
@@ -360,6 +366,9 @@ class MqttPublishAckTests(unittest.TestCase):
"int PsychicMqttClient::subscribe(",
"int PsychicMqttClient::publish(",
"PsychicMqttClient &PsychicMqttClient::setOutboxLimit(",
"size_t PsychicMqttClient::getOutboxSize()",
"size_t PsychicMqttClient::refreshOutboxSize()",
"void PsychicMqttClient::serviceOutboxDiagnostics()",
"unsigned long PsychicMqttClient::getOutboxDrops()",
"unsigned long PsychicMqttClient::getPublishOk()",
"unsigned long PsychicMqttClient::getPublishErr()",
@@ -401,6 +410,71 @@ class MqttPublishAckTests(unittest.TestCase):
def test_production_publish_events_and_slot_diagnostics(self):
self.compile_run(MAIN)
def test_outbox_snapshot_never_takes_cli_lock_and_tracks_enqueue_ack_and_expiry(self):
self.compile_run(r'''
static void dispatch(PsychicMqttClient& client,int id){
esp_mqtt_event_t event{id,41,false};
PsychicMqttClient::_onMqttEventStatic(&client,"MQTT",id,&event);
}
int main(){
PsychicMqttClient client;
assert(client.getOutboxSize()==0&&outbox_queries==0);
client._mqtt_cfg.uri="mqtt://broker";client._mqtt_cfg.broker.address.uri="mqtt://broker";
assert(client.connect()==ESP_OK);
dispatch(client,MQTT_EVENT_CONNECTED);
now_ms=1000;enqueue_added_size=120;
assert(client.publish("status",1,false,"body",4,true)==41);
assert(client.getOutboxSize()==120);
int queries=outbox_queries;
sdk_mutex_blocked=true;
for(int i=0;i<1000;++i)assert(client.getOutboxSize()==120);
assert(outbox_queries==queries); // A radio/CLI reader never waits for the SDK.
sdk_callback_owner=true;outbox_size=0;
dispatch(client,MQTT_EVENT_PUBLISHED); // recursive SDK-owned callback after removal
assert(client.getPublishAck()==1&&client.getOutboxSize()==0&&outbox_queries==queries+1);
sdk_callback_owner=false;sdk_mutex_blocked=false;
queries=outbox_queries;
for(int i=0;i<20;++i){now_ms+=1000;client.serviceOutboxDiagnostics();}
assert(outbox_queries==queries); // Quiet/empty outboxes never enter the SDK.
// Admission must use the SDK's fresh value, even when the CLI cache is old.
client.setOutboxLimit(200);outbox_size=200;
const int enqueued=enqueue_calls;
assert(client.getOutboxSize()==0);
assert(client.publish("status",1,false,"body",4,true)==-2&&enqueue_calls==enqueued);
assert(client.getOutboxSize()==200&&client.getOutboxDrops()==1);
outbox_size=0;
assert(client.publish("status",1,false,"body",4,true)==41);
assert(client.getOutboxSize()==120);
const unsigned long accepted=client.getPublishOk(),acks=client.getPublishAck();
queries=outbox_queries;
outbox_size=0;now_ms+=999;client.serviceOutboxDiagnostics();
assert(client.getOutboxSize()==120&&outbox_queries==queries);
++now_ms;client.serviceOutboxDiagnostics();
assert(client.getOutboxSize()==0&&outbox_queries==queries+1);
assert(client.getPublishOk()==accepted&&client.getPublishAck()==acks); // expiry is no ACK
now_ms=UINT32_MAX-500;
assert(client.publish("status",1,false,"body",4,true)==41);
queries=outbox_queries;outbox_size=0;
now_ms=498;client.serviceOutboxDiagnostics();assert(outbox_queries==queries);
now_ms=499;client.serviceOutboxDiagnostics();assert(outbox_queries==queries+1);
assert(client.getOutboxSize()==0);
assert(client.publish("status",1,false,"body",4,true)==41);
client.softDisconnect();
assert(client.isStarted()&&!client.connected()&&client.getOutboxSize()==120);
stop_result=ESP_ERR_INVALID_STATE;
client.forceStop();
assert(client.isStarted()&&client.getOutboxSize()==120); // failed stop retains ownership/cache
stop_result=ESP_OK;client.forceStop();
assert(!client.isStarted()&&!client.connected()&&client.getOutboxSize()==0);
assert(client.connect()==ESP_OK);dispatch(client,MQTT_EVENT_CONNECTED);
assert(client.publish("status",1,false,"body",4,true)==41);
client.disconnect();assert(!client.isStarted()&&client.getOutboxSize()==0);
}
''')
def test_production_initialized_getter_tracks_sdk_creation_and_retained_stops(self):
self.compile_run(r'''
int main() {
+137
View File
@@ -0,0 +1,137 @@
"""Run the production packet/raw/status admission paths with bounded heaps."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_mqtt_publish_ack import method
ROOT = Path(__file__).resolve().parents[1]
BRIDGE = 'src/helpers/bridges/MQTTBridge.cpp'
HARNESS = r'''
#include <atomic>
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstring>
#include "helpers/MQTTPublishAdmission.h"
#include "helpers/MQTTTransportHealth.h"
#define ESP_PLATFORM 1
#define BOARD_HAS_PSRAM 1
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
static uint32_t clock_ms=0;
unsigned long millis(){return clock_ms;}
constexpr int MALLOC_CAP_DMA=1, RUNTIME_MQTT_SLOTS=1;
static size_t dma_free=15348,dma_largest=9716,heap_queries=0;
size_t heap_caps_get_free_size(int){++heap_queries;return dma_free;}
size_t heap_caps_get_largest_free_block(int){++heap_queries;return dma_largest;}
struct Esp { size_t size=8000000; size_t getPsramSize(){return size;}
size_t getMaxAllocHeap(){++heap_queries;return dma_largest;} } ESP;
namespace mesh { struct Packet { uint8_t getPayloadType(){return 7;}
uint8_t writeTo(uint8_t*){return 2;} }; }
static int builds=0;
namespace MQTTMessageBuilder {
constexpr size_t WIRE_SCRATCH_SIZE=255;
bool canSerializePacket(mesh::Packet*,size_t){return true;}
template<typename... Args> int buildPacketJSONFromRaw(Args...){++builds;return 1;}
template<typename... Args> int buildPacketJSON(Args...){++builds;return 1;}
template<typename... Args> int buildRawJSON(Args...){++builds;return 1;}
}
struct Radio {float packetScore(float,int){return 0;}};
struct Client {int calls=0,result=0;uint32_t ack=0;
uint32_t getPublishAck(){return ack;}
int publish(const char*,uint8_t,bool,const char*,int,bool){++calls;return result;}
};
struct MQTTBridge {
struct MQTTSlot {Client* client=nullptr;bool enabled=true,connected=true;
unsigned long last_log_time=0;MQTTTransportHealth::State transport_health;};
MQTTSlot _slots[1];
std::atomic<bool> _stop_requested{false};
std::atomic<bool> _slot_health_reset_pending[1]{},_slot_transport_fault_pending[1]{};
MQTTPublishAdmission::State _publish_memory;
int _skipped_publishes=0;
static constexpr unsigned long SLOT_LOG_INTERVAL=30000;
static constexpr size_t PUBLISH_JSON_BUFFER_SIZE=2048;
enum {MSG_PACKETS,MSG_RAW};
char scratch[2048]{},*_json_scratch_buffer=scratch;
int _json_scratch_doc=0;
char _device_id[65]="ABC",_origin[32]="test";int _timezone=0;
uint8_t* _last_raw_data=nullptr;int _last_raw_len=0;
unsigned long _last_raw_timestamp=0;float _last_snr=0,_last_rssi=0;
Radio* _radio=nullptr;uint8_t eligible=1;
uint8_t eligiblePacketSlots(uint8_t,int){return eligible;}
void refreshOriginFromPrefs(){}
bool buildTopicForSlot(int,int,char*topic,size_t){strcpy(topic,"topic");return true;}
void updateSlotTransportDiag(int,uint32_t){}
bool hasPublishHeadroom(size_t=0,size_t=0,uint8_t=0);
bool publishToSlot(int,const char*,const char*,size_t,bool=false,uint8_t=0);
bool publishPacket(mesh::Packet*,bool,bool&,const uint8_t*=nullptr,int=0,float=0,float=0);
bool publishRaw(mesh::Packet*,bool&);
};
'''.replace('const uint8_t*=', 'const uint8_t* =')
MAIN = r'''
int main(){
using namespace MQTTPublishAdmission;
Heap heap;heap.psram=true;heap.dma_free=15348;heap.dma_largest=9716;
assert(!canPublish(heap)); // Exact screenshot: preserve the DMA reserve.
heap.dma_free+=5*897; // The five CPU-only reassembly buffers move to PSRAM.
assert(canPublish(heap)); // No new 16K internal allocation is needed to write.
heap.dma_free=16384;heap.dma_largest=4096;
assert(canPublish(heap,2048,128,0)&&canPublish(heap,1024,128,1));
--heap.dma_free;assert(!canPublish(heap));++heap.dma_free;
--heap.dma_largest;assert(!canPublish(heap));++heap.dma_largest;
assert(!canPublish(heap,10240,128,1)); // Large outbox records need more space.
heap.dma_free=24000;heap.dma_largest=11000;
assert(canPublish(heap,10240,128,1));
assert(!canPublish(heap,SIZE_MAX,1,1)&&!canPublish(heap,1,SIZE_MAX,0));
heap.psram=false;heap.internal_largest=7999;assert(!canPublish(heap));
heap.internal_largest=8000;assert(canPublish(heap));
State state;assert(state.needsSample(0));state.update(0,heap);
assert(!state.needsSample(0)&&!state.needsSample(4999)&&state.needsSample(5000));
state.update(UINT32_MAX-100,heap);
assert(!state.needsSample(50)&&state.needsSample(5000));
MQTTBridge bridge;Client client;bridge._slots[0].client=&client;
mesh::Packet packet;bool eligible=false;
assert(!bridge.publishPacket(&packet,false,eligible)&&eligible);
assert(!bridge.publishRaw(&packet,eligible)&&eligible);
assert(!bridge.publishToSlot(0,"status","{}",2,true,1));
assert(builds==0&&client.calls==0&&bridge._skipped_publishes==3);
assert(heap_queries==2); // Both denied paths cache the heap walk, even at tick0.
assert(bridge._slots[0].transport_health.recovery_reason==
MQTTTransportHealth::RecoveryReason::None);
dma_free=19833;clock_ms=5000;
assert(bridge.publishPacket(&packet,false,eligible));
assert(bridge.publishRaw(&packet,eligible));
assert(bridge.publishToSlot(0,"status","{}",2,true,1));
assert(builds==2&&client.calls==3&&heap_queries==4);
bridge.eligible=0;assert(!bridge.publishRaw(&packet,eligible)&&!eligible);
assert(builds==2&&client.calls==3);
client.result=-1;
assert(!bridge.publishToSlot(0,"raw","{}",2,false,0));
assert(!bridge._publish_memory.sampled); // Recheck after a real SDK failure.
}
'''
class MqttPublishAdmissionTests(unittest.TestCase):
def test_actual_publish_paths_and_policy(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler)
source = HARNESS + '\n'.join(method(BRIDGE, signature) for signature in (
'bool MQTTBridge::hasPublishHeadroom(', 'bool MQTTBridge::publishToSlot(',
'bool MQTTBridge::publishPacket(', 'bool MQTTBridge::publishRaw(')) + MAIN
with tempfile.TemporaryDirectory() as directory:
path, binary = Path(directory)/'publish.cpp', Path(directory)/'publish'
path.write_text(source)
result = subprocess.run([compiler,'-std=c++17','-Wall','-Wextra','-Werror',
'-I'+str(ROOT/'src'),str(path),'-o',str(binary)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
result = subprocess.run([str(binary)],capture_output=True,text=True,timeout=15)
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
if __name__=='__main__':
unittest.main()
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Run real MQTT allocation/lifetime and multipart delivery with heap failures."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
import test_mqtt_publish_ack as ack
ROOT = Path(__file__).resolve().parents[1]
MEMORY = r'''
#include <map>
#include <string>
#include "helpers/MQTTConnectionAdmission.h"
enum { MALLOC_CAP_INTERNAL=1, MALLOC_CAP_DMA=2, MALLOC_CAP_SPIRAM=4, MALLOC_CAP_8BIT=8 };
struct Allocation { size_t bytes; unsigned caps; };
static std::map<void*,Allocation> allocations;
static std::vector<Allocation> heap_requests;
static bool psram_ok=true, internal_ok=true, ordinary_ok=true;
static size_t dma_free=65536, dma_largest=16384;
static unsigned ordinary_allocs=0, ordinary_frees=0, caps_frees=0;
static int required_destroy_count=0;
static void* ordinary_malloc(size_t bytes) {
++ordinary_allocs;
if(!ordinary_ok)return nullptr;
void* p=std::malloc(bytes);assert(p);allocations[p]={bytes,0};return p;
}
static void ordinary_free(void* p) {
assert(destroy_calls>=required_destroy_count);
assert(allocations.count(p)&&allocations[p].caps==0);
allocations.erase(p);++ordinary_frees;std::free(p);
}
void* heap_caps_malloc(size_t bytes,unsigned caps) {
heap_requests.push_back({bytes,caps});
if((caps&MALLOC_CAP_SPIRAM)?!psram_ok:!internal_ok)return nullptr;
if(caps&MALLOC_CAP_INTERNAL){assert(bytes<=dma_free&&bytes<=dma_largest);dma_free-=bytes;}
void* p=std::malloc(bytes);assert(p);allocations[p]={bytes,caps};return p;
}
void heap_caps_free(void* p) {
assert(destroy_calls>=required_destroy_count);
assert(allocations.count(p)&&allocations[p].caps);
if(allocations[p].caps&MALLOC_CAP_INTERNAL)dma_free+=allocations[p].bytes;
allocations.erase(p);++caps_frees;std::free(p);
}
size_t heap_caps_get_free_size(unsigned caps) {
assert(caps==(MALLOC_CAP_INTERNAL|MALLOC_CAP_DMA));return dma_free;
}
size_t heap_caps_get_largest_free_block(unsigned caps) {
assert(caps==(MALLOC_CAP_INTERNAL|MALLOC_CAP_DMA));return dma_largest;
}
#define malloc ordinary_malloc
#define free ordinary_free
'''
MAIN = r'''
static void configure(PsychicMqttClient& client,int size=896) {
client._mqtt_cfg.uri="mqtt://test";
client._mqtt_cfg.broker.address.uri="mqtt://test";
client._mqtt_cfg.buffer_size=size;
client._mqtt_cfg.buffer.size=size;
}
static size_t allocated(){return ordinary_allocs+heap_requests.size();}
static void multipart(PsychicMqttClient& client,const std::string& text) {
char topic[]="test/data";
const size_t first=text.size()/3,second=2*text.size()/3;
for(size_t part=0;part<3;++part) {
size_t offset=part==0?0:part==1?first:second;
size_t end=part==0?first:part==1?second:text.size();
esp_mqtt_event_t event{MQTT_EVENT_DATA,0,false};
event.total_data_len=text.size();event.current_data_offset=offset;
event.data_len=end-offset;event.data=const_cast<char*>(text.data()+offset);
event.topic=topic;event.topic_len=sizeof(topic)-1;
esp_mqtt_event_handle_t pointer=&event;
client._onMessage(pointer);
}
}
int main() {
{
PsychicMqttClient client;configure(client);
// A successful PSRAM allocation needs no internal-heap headroom.
dma_free=0;dma_largest=0;
assert(client.connect()==ESP_OK&&client._buffer_capacity==896);
assert(allocations[client._buffer].bytes==897);
#if defined(BOARD_HAS_PSRAM)
assert(allocations[client._buffer].caps==(MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT));
assert(ordinary_allocs==0&&heap_requests.size()==1);
#else
assert(allocations[client._buffer].caps==0&&heap_requests.empty());
#endif
char* retained=client._buffer;
size_t count=allocated();
unsigned received=0;
std::string expected(896,'Q');
client.onMessage([&](char* topic,char* payload,int,int,bool){
assert(!strcmp(topic,"test/data")&&expected==payload);++received;
});
multipart(client,expected);assert(received==1&&allocated()==count);
multipart(client,std::string(897,'X'));assert(received==1&&allocated()==count);
client.disconnect();
assert(client._buffer==retained&&allocations.count(retained)&&allocated()==count);
assert(client.connect()==ESP_OK&&client._buffer==retained&&allocated()==count);
expected="after restart";multipart(client,expected);assert(received==2);
// Existing clients retain their original fixed reassembly allocation.
configure(client,3072);
assert(client.connect()==ESP_OK&&client._buffer==retained&&client._buffer_capacity==896);
assert(allocated()==count);
required_destroy_count=destroy_calls+1; // SDK ownership ends before buffer release.
}
assert(allocations.empty()&&destroy_calls==1);
#if defined(BOARD_HAS_PSRAM)
assert(caps_frees==1&&ordinary_frees==0);
psram_ok=false;
// Exact fallback boundary preserves the full DMA reserve after allocation.
dma_free=MQTTConnectionAdmission::kDmaReserveBytes+897;dma_largest=897;
{
PsychicMqttClient client;configure(client);
assert(client.connect()==ESP_OK&&client._buffer_capacity==896);
assert(allocations[client._buffer].caps==(MALLOC_CAP_INTERNAL|MALLOC_CAP_DMA|MALLOC_CAP_8BIT));
assert(dma_free==MQTTConnectionAdmission::kDmaReserveBytes);
}
assert(allocations.empty()&&caps_frees==2);
for(unsigned scenario=0;scenario<3;++scenario) {
dma_free=MQTTConnectionAdmission::kDmaReserveBytes+(scenario==0?896:897);
dma_largest=scenario==1?896:897;
internal_ok=scenario!=2;
PsychicMqttClient client;configure(client);
const int before=init_calls;
const size_t requests=heap_requests.size();
assert(client.connect()==ESP_ERR_NO_MEM);
assert(client._buffer==nullptr&&client._buffer_capacity==0&&!client.isInitialized());
assert(init_calls==before&&allocations.empty());
assert(heap_requests.size()==requests+(scenario==2?2:1));
psram_ok=true;
assert(client.connect()==ESP_OK&&client._buffer_capacity==896);
psram_ok=false;
}
internal_ok=true;psram_ok=true;
#else
assert(ordinary_frees==1&&caps_frees==0);
{
ordinary_ok=false;
PsychicMqttClient client;configure(client);
const int before=init_calls;
assert(client.connect()==ESP_ERR_NO_MEM&&!client._buffer&&!client._buffer_capacity);
assert(init_calls==before&&heap_requests.empty());
ordinary_ok=true;
assert(client.connect()==ESP_OK&&client._buffer_capacity==896);
}
#endif
assert(allocations.empty());
// Default and explicitly larger desired capacities still include one NUL.
for(int desired:{0,3072}) {
PsychicMqttClient client;configure(client,desired);
const size_t capacity=desired?desired:1024;
assert(client.connect()==ESP_OK&&client._buffer_capacity==capacity);
assert(allocations[client._buffer].bytes==capacity+1);
}
assert(allocations.empty());
}
'''
class MqttReassemblyBufferTest(unittest.TestCase):
def test_real_allocator_lifetime_recovery_and_multipart_capacity(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler)
event = """struct esp_mqtt_event_t { int event_id, msg_id; bool session_present;
int total_data_len=0,data_len=0,current_data_offset=0,topic_len=0,retain=0,qos=0;
char* data=nullptr;char* topic=nullptr;bool dup=false; };"""
mqtt = ack.MQTT_HEADER.replace(
"struct esp_mqtt_event_t { int event_id, msg_id; bool session_present; };", event)
preamble = ack.PREAMBLE.replace(
"void PsychicMqttClient::_onMessage(esp_mqtt_event_handle_t&) { ++receive_events; }", "")
source = preamble + MEMORY + "\n".join(ack.method(ack.ADAPTER, signature) for signature in (
"PsychicMqttClient::PsychicMqttClient()", "PsychicMqttClient::~PsychicMqttClient()",
"PsychicMqttClient &PsychicMqttClient::onMessage(", "int PsychicMqttClient::subscribe(",
"esp_err_t PsychicMqttClient::applyConfig()", "esp_err_t PsychicMqttClient::connect()",
"void PsychicMqttClient::disconnect()", "void PsychicMqttClient::_onMqttEventStatic(",
"void PsychicMqttClient::_onMqttEvent(", "void PsychicMqttClient::_onConnect(",
"void PsychicMqttClient::_onDisconnect(", "void PsychicMqttClient::_onSubscribe(",
"void PsychicMqttClient::_onUnsubscribe(", "void PsychicMqttClient::_onPublish(",
"size_t PsychicMqttClient::refreshOutboxSize()",
"void PsychicMqttClient::_onMessage(", "bool PsychicMqttClient::_isTopicMatch(",
)) + MAIN
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-buffer-") as directory:
directory = Path(directory)
(directory / "Arduino.h").write_text(ack.ARDUINO_HEADER, encoding="ascii")
(directory / "mqtt_client.h").write_text(mqtt, encoding="ascii")
(directory / "esp_crt_bundle.h").write_text("#pragma once\n", encoding="ascii")
path = directory / "buffer.cpp"
path.write_text(source, encoding="ascii")
for idf in (4, 5):
for psram in (False, True):
with self.subTest(idf=idf, psram=psram):
binary = directory / "buffer"
sanitize = (["-fsanitize=address,undefined", "-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-function", "-Wno-unused-variable", *sanitize,
"-DARDUINO_ARCH_ESP32=1", f"-DESP_IDF_VERSION_MAJOR={idf}",
*(["-DBOARD_HAS_PSRAM=1"] if psram else []),
"-I", str(directory), "-I", str(ROOT / "lib/PsychicMqttClient/src"),
"-I", str(ROOT / "src"), str(path), "-o", str(binary)]
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
"""Run actual slot identity and status scheduling methods without a broker."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_mqtt_transport_results import method
ROOT = Path(__file__).resolve().parents[1]
BRIDGE = "src/helpers/bridges/MQTTBridge.cpp"
PREAMBLE = r'''
#include <atomic>
#include <cassert>
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <functional>
#include <map>
#include <string>
#include <sys/time.h>
#ifdef _timezone
#undef _timezone
#endif
#include "helpers/MQTTStatusPublishPolicy.h"
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
constexpr int RUNTIME_MQTT_SLOTS = 6, MALLOC_CAP_INTERNAL = 1;
static uint32_t now_ms = 0;
static unsigned builds = 0;
static bool build_succeeds = true;
uint32_t millis() { return now_ms; }
size_t heap_caps_get_free_size(int) { return 100000; }
struct { uint64_t value = 0xAABBCCDDEEFF; uint64_t getEfuseMac() const { return value; } } ESP;
namespace MQTTMessageBuilder {
void formatIsoTimestampForMqtt(long, long, const char*, char* out, size_t size) { snprintf(out, size, "UTC"); }
template<typename... Args>
int buildStatusMessage(int, const char*, const char*, const char*, const char*,
const char*, const char*, const char*, const char*, char* buffer, int size, Args...) {
++builds;
if (!build_succeeds) return 0;
assert(size > 2); strcpy(buffer, "{}"); return 2;
}
}
void formatRadioInfo(float, float, int, int, char* out, size_t size) { snprintf(out, size, "radio"); }
struct Client {
const char* id = nullptr;
void setClientId(const char* value) { id = value; } // Real adapter retains this pointer.
};
struct Preset { bool allow_retain = false; };
struct MQTTBridge {
struct MQTTSlot {
Client* client = nullptr;
char client_id[32] = {};
bool enabled = false, connected = false;
const Preset* preset = nullptr;
MQTTStatusPublishPolicy::State status_publish;
} _slots[RUNTIME_MQTT_SLOTS];
Client clients[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _status_publish_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _stop_requested{false};
struct Observer { bool mqtt_status_enabled = true; } prefs;
Observer* _obs = &prefs;
bool accepted[RUNTIME_MQTT_SLOTS] = {true,true,true,true,true,true};
bool topic_enabled[RUNTIME_MQTT_SLOTS] = {true,true,true,true,true,true};
unsigned calls[RUNTIME_MQTT_SLOTS] = {};
std::function<void()> during_enqueue;
static constexpr int STATUS_JSON_BUFFER_SIZE = 1024, MSG_STATUS = 0;
static constexpr uint32_t STATUS_RETRY_INTERVAL = 30000;
uint32_t _status_interval = 300000;
struct { float* freq = nullptr; float* bw = nullptr; uint8_t* sf = nullptr; uint8_t* cr = nullptr; } _node_info;
const char *_timezone = "UTC", *_origin = "node", *_device_id = "ABCDEF";
const char *_board_model = "G3", *_firmware_version = "test";
int _json_scratch_doc = 0, _queue_count = 0;
char _json_scratch_buffer[STATUS_JSON_BUFFER_SIZE] = {};
struct Board { int getBattMilliVolts() const { return 4000; } };
struct Clock { uint32_t getMillis() const { return now_ms; } };
struct Dispatcher {
int getErrFlags() const { return 0; } int getTotalAirTime() const { return 0; }
int getReceiveAirTime() const { return 0; } int getNumSentFlood() const { return 0; }
int getNumSentDirect() const { return 0; } int getNumRecvFlood() const { return 0; }
int getNumRecvDirect() const { return 0; }
};
struct Radio { int getNoiseFloor() const { return -120; } int getPacketsRecvErrors() const { return 0; } };
Board* _board = nullptr; Clock* _ms = nullptr; Dispatcher* _dispatcher = nullptr; Radio* _radio = nullptr;
void refreshOriginFromPrefs() {}
void getClientVersion(char* out, size_t size) { snprintf(out, size, "test"); }
int repeatStatus() const { return 0; } int collectConnHealth() const { return 0; }
bool buildTopicForSlot(int i, int type, char* out, size_t size) {
assert(type == MSG_STATUS); snprintf(out, size, "status%d", i); return topic_enabled[i];
}
bool publishToSlot(int i, const char* topic, const char* payload, size_t length, bool retained, uint8_t qos) {
assert(qos == 1 && length == 2 && !strcmp(payload, "{}") && !strncmp(topic, "status", 6));
assert(retained == (_slots[i].preset ? _slots[i].preset->allow_retain : false));
++calls[i];
if (during_enqueue) { auto callback = during_enqueue; during_enqueue = nullptr; callback(); }
return accepted[i];
}
void connect(int count) {
for (int i = 0; i < count; ++i) {
_slots[i].enabled = _slots[i].connected = true; _slots[i].client = &clients[i];
_status_publish_pending[i] = true;
}
}
void setSlotClientId(int);
void maintainStatus(uint32_t);
bool publishStatus(uint8_t);
bool publishStatusToSlot(int);
};
'''
class MqttSlotStatusTests(unittest.TestCase):
def compile_run(self, main):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a native C++ compiler is required")
production = "\n".join(method(BRIDGE, signature) for signature in (
"void MQTTBridge::setSlotClientId(", "void MQTTBridge::maintainStatus(",
"bool MQTTBridge::publishStatus(", "bool MQTTBridge::publishStatusToSlot("))
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
with tempfile.TemporaryDirectory(prefix="mqtt-slot-status-") as directory:
source, binary = Path(directory) / "status.cpp", Path(directory) / "status"
source.write_text(PREAMBLE + production + main)
result = subprocess.run([compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror",
*flags, "-I", str(ROOT / "src"), str(source), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_duplicate_broker_slots_have_stable_distinct_persistent_client_ids(self):
setup = method(BRIDGE, "bool MQTTBridge::setupSlot(")
self.assertLess(setup.index("setSlotClientId(index)"), setup.index("reconnectSlotClient(index)"))
self.compile_run(r'''
int main() {
MQTTBridge bridge; bridge.connect(5);
std::map<std::string, unsigned> broker_sessions;
for (int i = 0; i < 5; ++i) {
bridge.setSlotClientId(i);
assert(bridge.clients[i].id == bridge._slots[i].client_id);
assert(strlen(bridge.clients[i].id) <= 23);
for (const char* p = bridge.clients[i].id; *p; ++p) assert(isalnum(static_cast<unsigned char>(*p)));
assert(broker_sessions.emplace(bridge.clients[i].id, i).second);
}
assert(broker_sessions.size() == 5);
assert(!strcmp(bridge.clients[0].id, "meshcoreAABBCCDDEEFFs1"));
char saved[32]; strcpy(saved, bridge.clients[1].id);
// Reconfigure/reconnect retains identity and storage despite stack churn.
for (unsigned n = 0; n < 100; ++n) {
volatile char scratch[128]; for (unsigned i = 0; i < sizeof(scratch); ++i) scratch[i] = char(i + n);
bridge.setSlotClientId(1);
assert(!strcmp(bridge.clients[1].id, saved));
}
assert(broker_sessions.size() == 5 && !bridge._slots[5].enabled);
MQTTBridge next_boot; next_boot.connect(1); next_boot.setSlotClientId(0);
assert(!strcmp(next_boot.clients[0].id, bridge.clients[0].id));
ESP.value ^= 1; next_boot.setSlotClientId(0);
assert(strcmp(next_boot.clients[0].id, bridge.clients[0].id));
}
''')
def test_failed_status_is_retried_per_slot_without_republishing_successful_slots(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge; bridge.connect(5);
bridge.accepted[1] = bridge.accepted[3] = false;
now_ms = 0; bridge.maintainStatus(now_ms);
assert(builds == 1);
for (int i = 0; i < 5; ++i) assert(bridge.calls[i] == 1);
assert(bridge._slots[0].status_publish.has_accepted && !bridge._slots[0].status_publish.pending);
assert(bridge._slots[1].status_publish.pending);
// Every 50 ms bridge tick remains bounded under prolonged memory/outbox rejection.
for (now_ms = 50; now_ms < 30000; now_ms += 50) bridge.maintainStatus(now_ms);
assert(builds == 1 && bridge.calls[1] == 1);
now_ms = 30000; bridge.accepted[1] = true; bridge.maintainStatus(now_ms);
assert(builds == 2 && bridge.calls[0] == 1 && bridge.calls[1] == 2 && bridge.calls[3] == 2);
for (now_ms = 30050; now_ms < 60000; now_ms += 50) bridge.maintainStatus(now_ms);
assert(builds == 2);
now_ms = 60000; bridge.accepted[3] = true; bridge.maintainStatus(now_ms);
assert(builds == 3 && bridge.calls[3] == 3 && bridge.calls[1] == 2);
now_ms = 300000; bridge.maintainStatus(now_ms);
assert(bridge.calls[0] == 2 && bridge.calls[2] == 2 && bridge.calls[4] == 2);
assert(bridge.calls[1] == 2 && bridge.calls[3] == 3);
now_ms = 330000; bridge.maintainStatus(now_ms); assert(bridge.calls[1] == 3);
now_ms = 360000; bridge.maintainStatus(now_ms); assert(bridge.calls[3] == 4);
}
''')
def test_json_failure_wraparound_reconnect_latch_and_modes_do_not_flood(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge; bridge.connect(2); bridge.topic_enabled[1] = false;
build_succeeds = false; now_ms = UINT32_MAX - 1000; bridge.maintainStatus(now_ms);
assert(builds == 1 && bridge.calls[0] == 0 && bridge._slots[0].status_publish.pending);
for (unsigned elapsed = 1; elapsed < 30000; elapsed += 50) {
now_ms = uint32_t(UINT32_MAX - 1000 + elapsed); bridge.maintainStatus(now_ms);
}
assert(builds == 1);
build_succeeds = true; now_ms = uint32_t(UINT32_MAX - 1000 + 30000); bridge.maintainStatus(now_ms);
assert(builds == 2 && bridge.calls[0] == 1 && bridge.calls[1] == 0);
bridge._slots[0].connected = false; now_ms += 300000; bridge.maintainStatus(now_ms);
assert(builds == 2);
bridge._slots[0].connected = true; bridge._status_publish_pending[0] = true;
bridge.during_enqueue = [&] { bridge._status_publish_pending[0] = true; };
bridge.maintainStatus(now_ms); assert(bridge.calls[0] == 2);
assert(bridge._status_publish_pending[0]);
bridge.maintainStatus(now_ms); assert(bridge.calls[0] == 3);
assert(!bridge._status_publish_pending[0]);
bridge.prefs.mqtt_status_enabled = false;
now_ms += 300000; bridge.maintainStatus(now_ms); assert(bridge.calls[0] == 3);
// Preserve the existing on-connect status even when periodic status is off.
bridge._status_publish_pending[0] = true; bridge.maintainStatus(now_ms); assert(bridge.calls[0] == 4);
bridge._stop_requested = true; bridge._status_publish_pending[0] = true;
bridge.maintainStatus(now_ms); assert(bridge.calls[0] == 4 && bridge._status_publish_pending[0]);
MQTTBridge stopping; stopping.connect(5);
stopping.during_enqueue = [&] { stopping._stop_requested = true; };
stopping.maintainStatus(now_ms);
assert(stopping.calls[0] == 1);
for (int i = 1; i < 5; ++i) assert(stopping.calls[i] == 0);
}
''')
if __name__ == "__main__":
unittest.main()
+13 -5
View File
@@ -224,7 +224,10 @@ struct MQTTBridge {
std::atomic<bool> _network_suspended{false};
bool _network_espnow_restore_pending = false;
struct Slot {
struct Client { bool isStarted() const { return false; } };
struct Client {
bool isStarted() const { return false; }
void serviceOutboxDiagnostics() {}
};
Client* client = nullptr;
bool enabled = true, initial_connect_done = false, connected = false;
unsigned long last_reconnect_attempt = 0;
@@ -282,9 +285,13 @@ struct MQTTBridge {
work(); ++reconfigures;
if (scenario == 8) _stop_requested = true;
}
void publishStatusToSlot(int) {
work(); ++publishes;
if (scenario == 9) _stop_requested = true;
void maintainStatus(uint32_t) {
for (int i = 0; i < RUNTIME_MQTT_SLOTS && !_stop_requested; ++i) {
if (!_status_publish_pending[i]) continue;
_status_publish_pending[i] = false;
work(); ++publishes;
if (scenario == 9) _stop_requested = true;
}
}
void maintainSlotConnections() {
work();
@@ -616,7 +623,8 @@ class MqttTransportResultsTests(unittest.TestCase):
source = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text(encoding="utf-8")
self.assertEqual(source.count("result = slot.client->connect();"), 1)
self.assertEqual(source.count("result = slot.client->reconnect();"), 1)
self.assertEqual(source.count("repeatStatus(), collectConnHealth()"), 2)
# On-connect and periodic status share one snapshot builder.
self.assertEqual(source.count("repeatStatus(), collectConnHealth()"), 1)
for signature in ("void MQTTBridge::teardownSlot(", "void MQTTBridge::destroySlotClients("):
body = method("src/helpers/bridges/MQTTBridge.cpp", signature)
self.assertIn("_slot_attempt_pending[", body)
+1
View File
@@ -145,6 +145,7 @@ struct Client {
int stops = 0;
uint32_t publish_acks = 0;
uint32_t getPublishAck() const { return publish_acks; }
void serviceOutboxDiagnostics() {}
bool isStarted() const { return started; }
bool connected() const { return live; }
void disconnect() {
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Exercise real JSON workspaces and authenticated handlers under allocation failure."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
import test_webconfig_lost_reply as config_fixture
import test_webconfig_login_throttle as login_fixture
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SERVER = (ROOT / 'src/helpers/esp32/WebConfigServer.cpp').read_text()
HEADER = (ROOT / 'src/helpers/esp32/WebConfigServer.h').read_text()
ALIASES = HEADER.split('class WebConfigServer {', 1)[1].split('public:', 1)[0]
HEAP = r'''
#pragma once
#include <cassert>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <map>
enum { MALLOC_CAP_INTERNAL=1, MALLOC_CAP_DMA=2, MALLOC_CAP_SPIRAM=4, MALLOC_CAP_8BIT=8 };
namespace heap_test {
struct Block { size_t size; bool psram; };
inline std::map<void*,Block> blocks;
inline size_t dma_free=65536,dma_largest=32768;
inline bool psram=true;
inline int successful_before_failure=-1;
inline unsigned psram_calls=0,internal_calls=0;
inline void reset() {
assert(blocks.empty()); dma_free=65536;dma_largest=32768;
psram=true;successful_before_failure=-1;psram_calls=internal_calls=0;
}
}
inline void* heap_caps_malloc(size_t size,unsigned caps) {
const bool external=caps&MALLOC_CAP_SPIRAM;
if(external)++heap_test::psram_calls;else ++heap_test::internal_calls;
if((external&&!heap_test::psram)||heap_test::successful_before_failure==0)return nullptr;
if(!external&&(size>heap_test::dma_largest||size>heap_test::dma_free))return nullptr;
void* pointer=malloc(size);if(!pointer)return nullptr;
if(heap_test::successful_before_failure>0)--heap_test::successful_before_failure;
heap_test::blocks[pointer]={size,external};
if(!external)heap_test::dma_free-=size;
return pointer;
}
inline void heap_caps_free(void* pointer) {
if(!pointer)return;
const auto found=heap_test::blocks.find(pointer);assert(found!=heap_test::blocks.end());
if(!found->second.psram)heap_test::dma_free+=found->second.size;
heap_test::blocks.erase(found);free(pointer);
}
inline size_t heap_caps_get_free_size(unsigned) { return heap_test::dma_free; }
inline size_t heap_caps_get_largest_free_block(unsigned) { return heap_test::dma_largest; }
inline size_t heap_caps_get_allocated_size(void* pointer) { return heap_test::blocks.at(pointer).size; }
'''
PRELUDE = '#define ESP_PLATFORM 1\n#define BOARD_HAS_PSRAM 1\n#include <helpers/esp32/WebConfigJsonAllocator.h>\n'
class WebConfigJsonAllocatorTests(unittest.TestCase):
def compile_run(self, source, *, psram=True):
json_header = next((ROOT / '.pio/libdeps').glob('*/ArduinoJson/src/ArduinoJson.h'))
with tempfile.TemporaryDirectory(prefix='webconfig-json-') as folder:
work = Path(folder)
(work / 'esp_heap_caps.h').write_text(HEAP)
cpp, binary = work / 'test.cpp', work / 'test'
cpp.write_text((PRELUDE if psram else '#include <helpers/esp32/WebConfigJsonAllocator.h>\n') + source)
sanitizers = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
result = subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra',
*sanitizers,
'-I'+str(work), '-I'+str(ROOT/'src'), '-I'+str(json_header.parent),
str(cpp), '-o', str(binary)], capture_output=True, text=True, timeout=45)
self.assertEqual(result.returncode, 0, result.stdout+result.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15)
self.assertEqual(run.returncode, 0, run.stdout+run.stderr)
def test_psram_allocations_realloc_failure_and_shrink_preserve_dma_reserve(self):
self.compile_run(r'''
int main() {
using namespace heap_test;
auto* allocator=mesh::webconfig::JsonAllocator::instance();
reset();
auto* first=static_cast<char*>(allocator->allocate(4096));assert(first&&internal_calls==0);
memset(first,'x',4096);const auto free_before=dma_free;
auto* larger=static_cast<char*>(allocator->reallocate(first,8192));
assert(larger&&dma_free==free_before&&internal_calls==0);
for(size_t i=0;i<4096;++i)assert(larger[i]=='x');
psram=false;dma_free=dma_largest=0;
const auto calls=psram_calls;
assert(allocator->reallocate(larger,32)==larger&&psram_calls==calls);
assert(!allocator->reallocate(larger,16384));
assert(blocks.count(larger)&&larger[4095]=='x');
assert(!allocator->reallocate(larger,0));assert(blocks.empty());
reset();psram=false;dma_free=16384+4096;dma_largest=4096;
first=static_cast<char*>(allocator->allocate(4096));assert(first&&dma_free==16384);
assert(!allocator->allocate(1));allocator->deallocate(first);
reset();psram=false;dma_free=16384+4096-1;dma_largest=4096;
assert(!allocator->allocate(4096)&&internal_calls==0);
dma_free=65536;dma_largest=4095;
assert(!allocator->allocate(4096)&&internal_calls==0);
reset();
{ mesh::webconfig::DynamicJsonDocument large(6656);
mesh::webconfig::StaticJsonDocument<96> small;
assert(large.capacity()==6656&&small.capacity()==96);
small["state"]="pending";
for(unsigned i=0;i<200;++i)large.add(i);
assert(!small.overflowed()&&!large.overflowed()&&internal_calls==0);
}
assert(blocks.empty());
}
''')
def config(self):
source = config_fixture.LostReplyWebConfigTest.fixture(self).split('int main() {', 1)[0]
source = source.replace('struct WebConfigServer {', 'struct WebConfigServer {'+ALIASES)
start = source.index('void WebConfigServer::handleConfigGet(')
end = source.index('\nvoid WebConfigServer::handleConfigPost(', start)
source = source[:start] + extract_braced(SERVER, 'void WebConfigServer::handleConfigGet(') + source[end:]
source = source.replace('struct AsyncWebServerRequest {',
'struct AsyncResponseStream : String { void addHeader(const char*,const char*) {} };\n'
'static void webConfigRegulatorsSnapshot(JsonObject) {}\nstruct AsyncWebServerRequest {')
source = source.replace(' Client socket;', ' AsyncResponseStream response;\n Client socket;')
source = source.replace(' void send(int code,',
' AsyncResponseStream* beginResponseStream(const char*) { return &response; }\n'
' void send(AsyncResponseStream* result) { status=200;output=*result; }\n void send(int code,')
source = source.replace(' Callbacks* _cb = &callbacks;',
' Callbacks* _cb = &callbacks;\n char _wifi_password[65]="saved-secret";\n'
' unsigned _wifi_power_save=0;\n')
return source
def test_actual_config_handlers_use_psram_and_reject_no_memory_before_queue_admission(self):
self.compile_run(self.config()+r'''
int main() {
using namespace heap_test;
assert(mesh::esp32::managementPassword::publish("password"));
reset();
{ WebConfigServer server;AsyncWebServerRequest get;server.handleConfigGet(&get);
assert(get.status==200&&psram_calls&&internal_calls==0);
assert(get.output.find("saved-secret")==String::npos);
JsonDocument response;assert(!deserializeJson(response,get.output));
assert(response["radio"]["freq"].is<double>());
}
reset();psram=false;dma_free=16384;
{ WebConfigServer server;AsyncWebServerRequest get;server.handleConfigGet(&get);
assert(get.status==503&&get.output=="{\"error\":\"not enough memory\"}");
assert(!internal_calls&&server._batch_state==WebConfigBatch::State::Idle);
}
// Fail every successive allocation, including construction of the accepted
// receipt. A failed JSON workspace cannot publish a partly parsed batch.
bool succeeded=false;
for(int permitted=0;permitted<30&&!succeeded;++permitted) {
reset();dma_free=16384;successful_before_failure=permitted;
{ WebConfigServer server;server.callbacks.node.capabilities=WebConfigServer::CAP_LOST_REPLY;
AsyncWebServerRequest post(writeBody("\"yes\""));server.handleConfigPost(&post);
if(post.status==202) {
succeeded=true;assert(server._batch_state==WebConfigBatch::State::Pending);
assert(server._batch_count==1&&!strcmp(server._batch[0].cmd,"set lost.reply yes"));
} else {
assert(post.status==503&&server._batch_state==WebConfigBatch::State::Idle);
assert(server._batch_count==0);
}
assert(!internal_calls);
}
}
assert(succeeded);reset();
{ WebConfigServer server;AsyncWebServerRequest post(writeBody("\"yes\""));
post.header.cookies="";psram=false;dma_free=0;server.handleConfigPost(&post);
assert(post.status==401&&psram_calls==0&&internal_calls==0);
}
}
''')
def test_actual_login_no_memory_does_not_count_as_wrong_password(self):
source = login_fixture.WebConfigLoginThrottleTest.handler_fixture(self).split('static const char* kLocked', 1)[0]
source = source.replace('struct WebConfigServer {', 'struct WebConfigServer {'+ALIASES)
self.assertIn('DeserializationError::NoMemory', source)
self.compile_run(source+r'''
int main() {
using namespace heap_test;
reset();WebConfigServer portal;
for(unsigned i=0;i<12;++i) {
AsyncWebServerRequest request;request.body="{\"password\":\"existing-secret\"}";
request._tempObject=request.body.data();psram=false;dma_free=16384;
portal.handleLogin(&request);assert(request.status==503&&internal_calls==0);
}
psram=true;
AsyncWebServerRequest request;request.body="{\"password\":\"existing-secret\"}";
request._tempObject=request.body.data();portal.handleLogin(&request);
assert(request.status==200&&portal._session_token[0]);
}
''')
def test_non_psram_keeps_original_arduinojson_document_types(self):
self.compile_run(r'''
#include <cassert>
#include <type_traits>
static_assert(std::is_same<mesh::webconfig::DynamicJsonDocument,ArduinoJson::DynamicJsonDocument>::value,"");
static_assert(std::is_same<mesh::webconfig::StaticJsonDocument<96>,ArduinoJson::StaticJsonDocument<96>>::value,"");
int main() {
mesh::webconfig::DynamicJsonDocument doc(6656);doc["key"]="value";
auto* allocator=mesh::webconfig::JsonAllocator::instance();
void* memory=allocator->allocate(16);assert(memory);
memory=allocator->reallocate(memory,32);assert(memory);allocator->deallocate(memory);
return doc.overflowed();
}
''', psram=False)
if __name__ == '__main__':
unittest.main()