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

+63 -11
View File
@@ -3,6 +3,10 @@
#include "PsychicMqttClient.h"
#include <string.h>
#if defined(BOARD_HAS_PSRAM)
#include <esp_heap_caps.h>
#include <helpers/MQTTConnectionAdmission.h>
#endif
static const char *TAG = "[OCTOPUS]";
@@ -27,11 +31,16 @@ PsychicMqttClient::~PsychicMqttClient()
{
esp_mqtt_client_destroy(_client);
_client = nullptr;
_outbox_size.store(0, std::memory_order_relaxed);
}
if (_buffer != nullptr)
{
#if defined(BOARD_HAS_PSRAM)
heap_caps_free(_buffer);
#else
free(_buffer);
#endif
_buffer = nullptr;
_buffer_capacity = 0;
}
@@ -428,7 +437,23 @@ esp_err_t PsychicMqttClient::connect()
if (_buffer == nullptr)
{
_buffer_capacity = (size_t)desired_buffer;
#if defined(BOARD_HAS_PSRAM)
// This character buffer is CPU-only; no atomics, DMA or ISR access.
// Retain it through reconnects just like the ordinary heap allocation.
const size_t bytes = _buffer_capacity + 1;
_buffer = static_cast<char*>(heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (_buffer == nullptr)
{
const uint32_t dma_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA;
if (MQTTConnectionAdmission::canFallback(bytes,
heap_caps_get_free_size(dma_caps), heap_caps_get_largest_free_block(dma_caps)))
{
_buffer = static_cast<char*>(heap_caps_malloc(bytes, dma_caps | MALLOC_CAP_8BIT));
}
}
#else
_buffer = (char *)malloc(_buffer_capacity + 1);
#endif
if (_buffer == nullptr)
{
ESP_LOGE(TAG, "Failed to allocate reassembly buffer (%u bytes)", (unsigned)_buffer_capacity);
@@ -517,6 +542,7 @@ void PsychicMqttClient::disconnect()
}
_connected = false;
_started = false;
_outbox_size.store(0, std::memory_order_relaxed);
ESP_LOGI(TAG, "MQTT client stopped.");
}
@@ -561,9 +587,16 @@ void PsychicMqttClient::forceStop()
{
ESP_LOGI(TAG, "Forced stop MQTT client.");
}
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_mqtt_client_stop(_client));
const esp_err_t result = esp_mqtt_client_stop(_client);
ESP_ERROR_CHECK_WITHOUT_ABORT(result);
if (result != ESP_OK)
{
ESP_LOGW(TAG, "Forced MQTT stop failed: %s", esp_err_to_name(result));
return;
}
_connected = false;
_started = false;
_outbox_size.store(0, std::memory_order_relaxed);
ESP_LOGI(TAG, "MQTT client forcefully stopped.");
}
@@ -604,18 +637,18 @@ int PsychicMqttClient::publish(const char *topic, int qos, bool retain, const ch
// own (esp-mqtt frees entries only on send-ack or time-based expiry), so on a
// stalled uplink QoS0 frames pile up on internal heap without limit. Cap it
// here: if the outbox is already at/over _outbox_limit, drop this QoS0 message
// and report -2 ("outbox full") so the caller can retry/drop. QoS1/2 (durable,
// low-rate) are never gated.
if (qos == 0 && _outbox_limit > 0 && _client != nullptr &&
esp_mqtt_client_get_outbox_size(_client) >= _outbox_limit)
// and report -2 ("outbox full") so the caller can retry/drop. Apply the
// same bound to durable status messages on a stalled connection.
const size_t outbox_size = refreshOutboxSize();
if (_outbox_limit > 0 && _client != nullptr && outbox_size >= _outbox_limit)
{
_outbox_drops++;
static unsigned long last_full_log = 0;
unsigned long now = millis();
if (now - last_full_log > 5000)
{
ESP_LOGW(TAG, "Outbox at cap (%u bytes); dropping QoS0 message to topic %s",
(unsigned)_outbox_limit, topic);
ESP_LOGW(TAG, "Outbox at cap (%u bytes); dropping QoS%d message to topic %s",
(unsigned)_outbox_limit, qos, topic);
last_full_log = now;
}
return -2;
@@ -625,6 +658,7 @@ int PsychicMqttClient::publish(const char *topic, int qos, bool retain, const ch
bool store_in_outbox = true;
int result = esp_mqtt_client_enqueue(_client, topic, payload, length, qos, retain, store_in_outbox);
if (result < 0) _publish_err++; else _publish_ok++;
refreshOutboxSize();
return result;
}
else
@@ -635,6 +669,7 @@ int PsychicMqttClient::publish(const char *topic, int qos, bool retain, const ch
// callers can surface delivery health without per-message logging.
int result = esp_mqtt_client_publish(_client, topic, payload, length, qos, retain);
if (result < 0) _publish_err++; else _publish_ok++;
if (qos > 0) refreshOutboxSize(); else serviceOutboxDiagnostics();
return result;
}
}
@@ -661,10 +696,24 @@ PsychicMqttClient &PsychicMqttClient::setOutboxLimit(size_t bytes)
size_t PsychicMqttClient::getOutboxSize()
{
if (_client == nullptr)
return 0;
int size = esp_mqtt_client_get_outbox_size(_client);
return size > 0 ? (size_t)size : 0;
return _outbox_size.load(std::memory_order_relaxed);
}
size_t PsychicMqttClient::refreshOutboxSize()
{
const int size = _client ? esp_mqtt_client_get_outbox_size(_client) : 0;
const size_t bytes = size > 0 ? static_cast<size_t>(size) : 0;
_outbox_size.store(bytes, std::memory_order_relaxed);
_outbox_sampled_at.store(static_cast<uint32_t>(millis()), std::memory_order_relaxed);
return bytes;
}
void PsychicMqttClient::serviceOutboxDiagnostics()
{
if (!_started.load(std::memory_order_relaxed) || !getOutboxSize()) return;
const uint32_t now = static_cast<uint32_t>(millis());
if (now - _outbox_sampled_at.load(std::memory_order_relaxed) >= 1000U)
refreshOutboxSize();
}
size_t PsychicMqttClient::getOutboxLimit()
@@ -862,6 +911,9 @@ void PsychicMqttClient::_onMessage(esp_mqtt_event_handle_t &event)
void PsychicMqttClient::_onPublish(esp_mqtt_event_handle_t &event)
{
// The SDK removes the acknowledged item before dispatch and its API mutex
// is recursive, so this callback can sample without waiting on another task.
refreshOutboxSize();
_publish_ack.fetch_add(1, std::memory_order_relaxed);
ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED, msg_id=%d", event->msg_id);
for (uint8_t i = 0; i < _onPublishUserCallbackCount; ++i)
+18 -8
View File
@@ -436,15 +436,15 @@ public:
esp_mqtt_client_config_t *getMqttConfig();
/**
* @brief Caps the size of the esp-mqtt outbox for async QoS 0 publishes.
* @brief Caps the size of the esp-mqtt outbox for asynchronous publishes.
*
* QoS 0 async publishes are forced into the esp-mqtt outbox (store=true) so
* packet topics keep flowing, but the outbox has no size bound of its own --
* it only frees entries on send-ack or time-based expiry. On a stalled uplink
* the entries accumulate on internal heap without limit. When the current
* outbox size is at/over this cap, publish() drops the new QoS 0 message
* outbox size is at/over this cap, publish() drops the new message
* (returns -2) instead of enqueuing it, applying backpressure. 0 disables the
* cap. QoS 1/2 publishes are never gated by this.
* cap. The publish owner samples the SDK immediately before admission.
*
* @param bytes Maximum outbox size in bytes, or 0 to disable.
* @return A reference to the PsychicMqttClient instance.
@@ -452,13 +452,18 @@ public:
PsychicMqttClient &setOutboxLimit(size_t bytes);
/**
* @brief Returns the current esp-mqtt outbox size in bytes (0 if the client
* is not initialized). Useful for diagnostics/backpressure monitoring.
* @brief Returns the last owner-sampled outbox size without taking the SDK
* mutex. Publish/ACK events update it; nonempty idle outboxes refresh within
* one second when the owner calls serviceOutboxDiagnostics().
*
* @return Current outbox size in bytes.
*/
size_t getOutboxSize();
// Call only from the client's network owner, never a radio/CLI reader.
// Expiry can remove queued items without a broker acknowledgment event.
void serviceOutboxDiagnostics();
/**
* @brief Returns the configured outbox cap in bytes (0 = disabled). Lets
* callers confirm the cap is actually applied to this client.
@@ -466,7 +471,7 @@ public:
size_t getOutboxLimit();
/**
* @brief Returns the cumulative count of QoS0 messages dropped because the
* @brief Returns the cumulative count of async messages dropped because the
* outbox was at/over the cap. Monotonic; useful for backpressure diagnostics.
*/
unsigned long getOutboxDrops();
@@ -500,10 +505,12 @@ private:
bool _config_dirty = true;
std::atomic<bool> _started{false};
// Runtime cap on the esp-mqtt outbox for QoS 0 async publishes (bytes).
// Runtime cap on the esp-mqtt outbox for async publishes (bytes).
// 0 = disabled. Enforced in publish(); not an esp-mqtt config field.
size_t _outbox_limit = 0;
// Cumulative count of QoS0 publishes dropped because the outbox hit the cap.
std::atomic<size_t> _outbox_size{0};
std::atomic<uint32_t> _outbox_sampled_at{0};
// Cumulative count of async publishes dropped because the outbox hit the cap.
unsigned long _outbox_drops = 0;
// Cumulative publish accept/fail counts (any QoS, sync or async path).
unsigned long _publish_ok = 0;
@@ -513,6 +520,8 @@ private:
// Multipart message reassembly. _buffer is lazily allocated at connect() time
// to match the configured buffer size, then reused for the client's lifetime.
// PSRAM builds prefer external byte storage; internal fallback preserves
// the network/AES DMA reserve. The client object and task stacks stay internal.
// _topic is inline storage, never heap-allocated.
char *_buffer = nullptr;
size_t _buffer_capacity = 0;
@@ -523,6 +532,7 @@ private:
// Configuration is transactional: never start/reconnect on stale credentials
// after a failed esp_mqtt_set_config().
esp_err_t applyConfig();
size_t refreshOutboxSize();
bool _isTopicMatch(const char *topic, const char *subscription);
// Fixed-size callback storage. Each slot is "used" once registered; we never