fix(mqtt): renew JWT credentials without stopping the esp-mqtt client

The scheduled JWT bounce called PsychicMqttClient::disconnect(), which ends
with esp_mqtt_client_stop(). That ends the client task and returns its 6 KiB
stack to the heap at the moment the TLS teardown vacates two 16 KiB mbedTLS
record buffers, so the stack lands in that hole and the next handshake cannot
reuse it. On non-PSRAM boards the largest free block then ratchets down 16 KiB
at a time while total free heap stays flat.

Soak evidence from a Heltec V3 on 8d1a0eb3: 43 of 60 disconnects had no
preceding transport error, i.e. they were this proactive bounce rather than a
broker FIN, and two of the three max_alloc steps landed within 5 s of one.
Losing a whole TLS session later returned exactly 16,384 bytes of contiguity.

softDisconnect() closes the transport without the stop, so the task and its
stack stay put across the handshake. The bounce uses it plus reconnect(), and
falls back to connect() when the client really is stopped, since reconnect()
is a silent no-op in that state.

Also corrects a comment claiming the mbedTLS context survives a transport
close: only the esp-mqtt client object does.

(cherry picked from commit 10cf5cf48fb009e751e25b37fcc1f3d1256ddbbc)
This commit is contained in:
agessaman committed 2026-08-14 09:47:40 -07:00
1 parent 40635a53c4
commit 4c90db2199
4 files changed
+97 -8

No files matched your search

+22 -7
View File
@@ -1775,9 +1775,11 @@ bool MQTTBridge::setupSlot(int index) {
}
// Reconfigure path: if we're re-applying (e.g. after a preset change), stop
// the existing connection cleanly first. The client object (and its mbedTLS
// context) is reused; setCredentials / setServer below overwrite the config
// fields in place before connect() restarts the ESP-IDF client.
// the existing connection cleanly first. The client object is reused, but its
// mbedTLS context is NOT — closing the transport destroys the TLS session,
// record buffers, and peer certificate, and the next connect() reallocates
// them. setCredentials / setServer below overwrite the config fields in place
// before connect() restarts the ESP-IDF client.
if (slot.initial_connect_done) {
if (slot.client->connected()) {
slot.client->disconnect();
@@ -2140,11 +2142,24 @@ void MQTTBridge::maintainSlotConnection(int index, unsigned long now_millis, uns
// Disconnect + reconnect with fresh credentials, reusing existing client
// to avoid internal heap leak/fragmentation from destroy/create cycles
MQTT_DEBUG_PRINTLN("MQTT%d token renewal: reconnecting with fresh credentials", index + 1);
if (slot.client->connected()) {
slot.client->disconnect(); // stops the client internally
MQTT_TRACE_HEAP("renewal:before-bounce", index);
if (slot.client->isStarted()) {
// Keep the esp-mqtt task alive across the handshake. disconnect()
// would stop it, returning its 6 KiB stack into the hole the two
// 16 KiB mbedTLS record buffers just vacated — which is what walks
// the largest free block down 16 KiB at a time on non-PSRAM boards.
slot.client->softDisconnect();
MQTT_TRACE_HEAP("renewal:after-disconnect", index);
slot.client->setCredentials(_jwt_username, slot.auth_token);
MQTT_TRACE_HEAP("renewal:after-credentials", index);
slot.client->reconnect();
} else {
// Client was stopped (teardown/reconfigure). reconnect() is a no-op
// on a stopped client, so this path must start it.
slot.client->setCredentials(_jwt_username, slot.auth_token);
slot.client->connect();
}
slot.client->setCredentials(_jwt_username, slot.auth_token);
slot.client->connect(); // restart stopped client; reconnect() fails silently on a stopped client
MQTT_TRACE_HEAP("renewal:after-reconnect", index);
reconnect_attempted = true;
_last_slot_reconnect_ms = now_millis;
MQTT_DEBUG_PRINTLN("MQTT%d int_heap=%d at token renewal reconnect", index + 1,
+13
View File
@@ -36,6 +36,19 @@ class MeshSNMPAgent; // Forward declaration
#define MQTT_DEBUG_PRINTLN(...) {}
#endif
// Largest-free-block trace around the reconnect lifecycle. On non-PSRAM boards the
// mbedTLS record buffers are two 16 KiB internal-DRAM blocks, so what matters is the
// largest contiguous block, not the free total — a soak can show flat free heap while
// max_alloc walks down. Costs two heap_caps calls per reconnect, so it stays on.
#if defined(MQTT_DEBUG) && defined(ARDUINO) && defined(ESP32)
#define MQTT_TRACE_HEAP(point, idx) \
MQTT_DEBUG_PRINTLN("HEAPTRACE slot=%d %s free=%u max=%u", (int)(idx) + 1, point, \
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), \
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL))
#else
#define MQTT_TRACE_HEAP(point, idx) do {} while(0)
#endif
#ifdef WITH_MQTT_BRIDGE
// Periodic neighbors publication keys off the mesh neighbor cache (sized by