Recover stalled MQTT transports and broker acknowledgments

This commit is contained in:
mikecarper committed 2026-10-14 10:34:40 -07:00
1 parent 8a49a98709
commit 3347a62780
17 files changed
+1201 -23

No files matched your search

+1
View File
@@ -246,6 +246,7 @@ jobs:
python3 -B test/test_mqtt_disconnect.py -v
python3 -B test/test_mqtt_connection_admission.py -v
python3 -B test/test_mqtt_handshake_scheduler.py -v
python3 -B test/test_mqtt_transport_health.py -v
python3 -B test/test_mqtt_diagnostics.py -v
python3 -B test/test_mqtt_publish_ack.py -v
python3 -B test/test_wifi_network_power_policy.py -v
+14
View File
@@ -734,6 +734,20 @@ get mqtt1.diag
get mqtt2.diag
```
For a connection that works initially but later stops reporting, inspect each
configured slot's `pub_ack` count in `get mqttN.diag`. Accepted publishes are
not proof of delivery: a QoS 1 publish is confirmed only when the broker
acknowledges it. A quiet radio channel alone is not a failed MQTT connection.
The shared MQTT bridge recovers failed transports on its owning task, including
errors without a subsequent disconnect callback and connection attempts that
never finish. It also watches accepted QoS 1 publishes for missing broker
acknowledgments. Recovery retains the existing broker configuration, memory
admission and reconnect backoff; it does not reboot the node or cycle WiFi.
See the [MQTT recovery regression](test-results/mqtt_tls_v4_regression.md#long-running-mqtt-recovery)
for the deadlines, test coverage and hardware limitations. Older preview images
do not gain these changes until their firmware is updated.
Common causes are:
- no saved SSID or a changed password;
+9
View File
@@ -518,6 +518,15 @@ clear, set, or toggle selected bits.
Prints the runtime diagnostic summary for MQTT slot N, including its connection
state and failure history.
`pub_ack` counts actual broker acknowledgments, not merely accepted writes.
Recent firmware also shows `rec:<count>/<reason>` for transport recovery and
`ack_wait:<seconds>s` while an accepted QoS 1 publication awaits acknowledgment.
Reasons include `error`, `state`, `handshake`, `write`, and `puback`. `recover`
means cleanup is pending, not that the broker is healthy. Optional fields are
omitted if they cannot fit completely in the reply. `rec` counts completed
client cleanup; a higher count alone does not prove the subsequent connection
or delivery succeeded.
<a id="mqtt-stats"></a>
### `get mqtt.stats`
@@ -199,3 +199,69 @@ Comparison removes console echo/prompt framing and treats the old blank
`get wifi.ssid` response as the new `(not configured)` response. MQTT and
power saving were both returned to their original off state. No release assets
were uploaded as part of this test.
## Long-running MQTT recovery
The [14 October 2026 follow-up report](https://github.com/mikecarper/meshfirmware/issues/16#issuecomment-6169059046)
says the preview initially connects, but three reporting sites show it offline
after roughly three hours while WiFi and the console still show connected.
The earlier 180-second trial does not qualify this longer-running workload.
Code inspection found recovery gaps, not a verified cause of that hardware
failure. The adapter clears its connection flag on an MQTT error, but the
bridge previously left its separate flag set until a disconnect callback.
Its five-second cache refresh only reread that same flag. A lost terminal
callback could also leave a connection attempt pending indefinitely, blocking
the other brokers' handshake and renewal requests. Accepted status enqueues
were treated as successful publication without using the existing PUBACK
counter to detect stalled delivery.
Recovery now runs on the bridge's owning task. An errored or timed-out SDK
client must be proven stopped before its pending-handshake reservation is
released. The client object and credentials are retained, and the existing
admission checks and reconnect backoff still apply. No WiFi reset, node reboot,
extra periodic heartbeat, or broker credential change is required. An idle
connection with no outstanding QoS 1 publication is not timed out merely for
being quiet.
Both the pending-handshake deadline and the outstanding-PUBACK deadline are
120 seconds. This leaves a generous margin over the SDK's 10-second network
timeout and the configured 15-second QoS 1 retransmission interval. A failed
SDK stop retains its recovery reservation instead of permitting another
overlapping TLS handshake.
CLI and WebConfig slot status show `recover` during reserved cleanup.
`get mqttN.diag` retains `pub_ack` and adds a bounded recovery count/reason
(`rec`) and pending acknowledgment age (`ack_wait`) when they fit. A delayed
connected callback cannot cancel cleanup after an SDK stop failed.
The delivery watch starts with the first accepted QoS 1 enqueue and observes
real broker acknowledgments, including the on-connect status publication.
Further enqueues cannot postpone that oldest outstanding deadline. Timeouts
are serviced on the next owning-task pass; they are not a guarantee of
wall-clock recovery if the worker is blocked inside an SDK call. This change
does not establish or repair an indefinite SDK mutex deadlock.
All 251 native MQTT test cases and 79 MQTT Python host tests passed. The new
tests execute the production callbacks, publishing, maintenance, recovery and
admission methods with controllable SDK calls and clocks. They include three
brokers running beyond three simulated hours, missing PUBACKs and callbacks,
silent synchronous write failures, failed SDK stops, late connected events,
WiFi loss, suspension/foreground wake, backoff, memory admission and timer
rollover. Removing the new error-callback recovery signal makes its regression
test fail, establishing that it detects the previous behavior. AP ownership
fixtures also passed with address/undefined-behavior sanitizers. The six pure
transport-health tests are included in the 79-test total and in CI.
The exact Full `heltec_v4_repeater_observer_mqtt` image compiled and linked
against Arduino 2.0.17 / ESP-IDF 4.4.7. This local test image used baseline
`dce323e37` with the MQTT repair applied; it predates the subsequently pulled
beacon changes. Its application size was 2,237,677
bytes, within the 6,553,600-byte application slot. The linked runtime RAM gate
reported 204,752 internal bytes available, 198,432 required and a 171,984-byte
largest region. These are build-time capacity/admission checks, not free heap
measured with three live brokers or qualification of maximum simultaneous load.
No new physical three-hour V4 soak or reporter-side retest has been performed
as part of this repair. These tests establish the repaired recovery behavior,
not the cause of the reporter's hardware failure or website-side availability.
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <stdint.h>
// Owner-task transport recovery, separate from the reconnect/backoff policy.
// A CONNECTED callback or accepted/enqueued publish is not proof of delivery.
namespace MQTTTransportHealth {
// Twelve times the normal 10-second socket budget gives DNS/TLS/WebSocket/
// CONNACK setup ample room, while bounding a lost final callback. PUBACK uses
// the same generous budget: longer than the 15-second retry and SDK outbox
// expiry, so an ordinary retransmit cannot trigger a premature reconnect.
static const uint32_t kAttemptTimeoutMs = 120000UL;
static const uint32_t kAckTimeoutMs = 120000UL;
enum class RecoveryReason : uint8_t {
None, TransportError, AdapterDisconnected, HandshakeTimeout,
PublishFailure, AckTimeout
};
inline const char* reasonLabel(RecoveryReason reason) {
switch (reason) {
case RecoveryReason::TransportError: return "error";
case RecoveryReason::AdapterDisconnected: return "state";
case RecoveryReason::HandshakeTimeout: return "handshake";
case RecoveryReason::PublishFailure: return "write";
case RecoveryReason::AckTimeout: return "puback";
default: return "none";
}
}
struct State {
bool attempt_active = false;
uint32_t attempt_started_ms = 0;
bool ack_pending = false;
uint32_t ack_started_ms = 0;
uint32_t ack_snapshot = 0;
RecoveryReason recovery_reason = RecoveryReason::None;
RecoveryReason last_recovery_reason = RecoveryReason::None;
uint32_t recoveries = 0;
void beginAttempt(uint32_t now) {
attempt_active = true;
attempt_started_ms = now;
}
void completeAttempt() { attempt_active = false; }
void observeAck(uint32_t count) {
if (ack_pending && count != ack_snapshot) ack_pending = false;
}
void acceptedQos1(uint32_t now, uint32_t ack_before) {
observeAck(ack_before);
if (!ack_pending) {
ack_pending = true;
ack_started_ms = now;
ack_snapshot = ack_before;
}
}
bool attemptExpired(uint32_t now) const {
return attempt_active && now - attempt_started_ms >= kAttemptTimeoutMs;
}
bool ackExpired(uint32_t now) const {
return ack_pending && now - ack_started_ms >= kAckTimeoutMs;
}
void requestRecovery(RecoveryReason reason) {
if (recovery_reason == RecoveryReason::None) recovery_reason = reason;
}
void resetSession() {
attempt_active = false;
ack_pending = false;
}
void recovered() {
if (recoveries != UINT32_MAX) ++recoveries;
last_recovery_reason = recovery_reason;
recovery_reason = RecoveryReason::None;
resetSession();
}
};
} // namespace MQTTTransportHealth
+242 -13
View File
@@ -426,6 +426,10 @@ void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPref
} else if (!b->isSlotReady(i)) {
name = slot.preset ? slot.preset->name : "custom";
state = "wait";
} else if (b->_slot_health_recovering[i].load(std::memory_order_acquire) ||
b->_slot_transport_fault_pending[i].load(std::memory_order_acquire)) {
name = slot.preset ? slot.preset->name : "custom";
state = "recover";
} else if (slot.connected) {
name = slot.preset ? slot.preset->name : "custom";
state = "ok";
@@ -551,6 +555,9 @@ bool MQTTBridge::getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out)
out->name = slot.preset ? slot.preset->name : "custom";
if (!b->isSlotReady(slot_index)) {
out->state = "wait";
} else if (b->_slot_health_recovering[slot_index].load(std::memory_order_acquire) ||
b->_slot_transport_fault_pending[slot_index].load(std::memory_order_acquire)) {
out->state = "recover";
} else if (slot.connected) {
out->state = "ok";
} else if (slot.circuit_breaker_tripped) {
@@ -657,6 +664,9 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
} else if (!slot.client) {
// Ready to connect but the client object could not be allocated.
state = "no client";
} else if (b->_slot_health_recovering[slot_index].load(std::memory_order_acquire) ||
b->_slot_transport_fault_pending[slot_index].load(std::memory_order_acquire)) {
state = "recover";
} else if (slot.connected) {
state = "ok";
} else if (slot.circuit_breaker_tripped) {
@@ -689,7 +699,7 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
}
// Connected with no errors: nothing more to say about the connection.
if (slot.connected && slot.last_error_time == 0) {
if (slot.connected && strcmp(state, "ok") == 0 && slot.last_error_time == 0) {
replyAppendf(buf, bufsize, &pos, ", no errors");
} else if (slot.last_error_time > 0) {
// TLS error with human-friendly description
@@ -743,6 +753,28 @@ void MQTTBridge::formatSlotDiagReply(char* buf, size_t bufsize, int slot_index)
}
}
// Recovery/ACK age are owner-task snapshots. Append complete fields only;
// error details and the existing broker ACK count retain reply priority.
const uint32_t recoveries = b->_slot_health_recoveries[slot_index].load(std::memory_order_relaxed);
const auto recovery_reason = static_cast<MQTTTransportHealth::RecoveryReason>(
b->_slot_health_reason[slot_index].load(std::memory_order_relaxed));
if (recoveries || recovery_reason != MQTTTransportHealth::RecoveryReason::None) {
char recovery_text[48];
const int len = snprintf(recovery_text, sizeof(recovery_text), ", rec:%lu/%s",
static_cast<unsigned long>(recoveries), MQTTTransportHealth::reasonLabel(recovery_reason));
if (len > 0 && len < (int)sizeof(recovery_text) && len <= (int)bufsize - 1 - pos) {
replyAppendf(buf, bufsize, &pos, "%s", recovery_text);
}
}
const uint32_t ack_wait = b->_slot_health_ack_wait_secs[slot_index].load(std::memory_order_relaxed);
if (ack_wait != UINT32_MAX) {
char wait_text[32];
const int len = snprintf(wait_text, sizeof(wait_text), ", ack_wait:%lus", static_cast<unsigned long>(ack_wait));
if (len > 0 && len < (int)sizeof(wait_text) && len <= (int)bufsize - 1 - pos) {
replyAppendf(buf, bufsize, &pos, "%s", wait_text);
}
}
// Appended last so it never displaces connection diagnostics. replyAppendf
// clamps rather than overflows, but a clipped type list is worse than no
// list: "...,13,14," parses as a real, different allowlist, and this is the
@@ -875,11 +907,11 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs,
// Initialize all slots to empty/disabled state
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
memset(&_slots[i], 0, sizeof(MQTTSlot));
_slots[i] = MQTTSlot{};
_slots[i].enabled = false;
_slots[i].client = nullptr;
_slots[i].preset = nullptr;
// auth_token == nullptr after memset above - allocated on first token creation
// auth_token == nullptr after value initialization - allocated on first token creation
_slots[i].connected = false;
_slots[i].initial_connect_done = false;
_slots[i].token_expires_at = 0;
@@ -893,6 +925,13 @@ MQTTBridge::MQTTBridge(const MQTTNodeInfo& node_info, MQTTPrefs *obs,
_slot_reconfigure_pending[i] = false;
_slot_force_jwt_mint[i] = false;
_slot_attempt_pending[i] = false;
_slot_transport_fault_pending[i] = false;
_slot_health_reset_pending[i] = false;
_slots[i].transport_health = MQTTTransportHealth::State();
_slot_health_recoveries[i] = 0;
_slot_health_reason[i] = static_cast<uint8_t>(MQTTTransportHealth::RecoveryReason::None);
_slot_health_recovering[i] = false;
_slot_health_ack_wait_secs[i] = UINT32_MAX;
_status_publish_pending[i] = false;
}
@@ -1024,6 +1063,11 @@ void MQTTBridge::begin() {
_slots[i].broker_uri[0] = '\0';
_slots[i].port = 1883;
_slot_reconfigure_pending[i] = false;
_slot_transport_fault_pending[i] = false;
_slot_health_reset_pending[i] = false;
_slots[i].transport_health.resetSession();
_slots[i].transport_health.recovery_reason = MQTTTransportHealth::RecoveryReason::None;
updateSlotTransportDiag(i, static_cast<uint32_t>(millis()));
}
_cached_has_connected_slots = false;
_slots_setup_done = false;
@@ -1617,13 +1661,33 @@ bool MQTTBridge::serviceNetworkPower(uint32_t now) {
cancelNtpRefresh();
_ntp_sync_pending = false;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
// A foreground/probe request can resume the network policy after a
// failed suspension. Keep a transport reservation until stop is proven.
if (_slots[i].client && _slots[i].client->isStarted()) {
_slot_transport_fault_pending[i].store(true, std::memory_order_release);
}
_slot_attempt_pending[i] = false;
if (_slots[i].client && _slots[i].client->isStarted()) {
_slots[i].client->disconnect();
// The wrapper only clears isStarted after SDK stop succeeds. Never
// hand station ownership off while any transport still runs.
if (_slots[i].client->isStarted()) return false;
if (_slots[i].client->isStarted()) {
_slots[i].transport_health.requestRecovery(MQTTTransportHealth::RecoveryReason::AdapterDisconnected);
_slots[i].connected = false;
_slots[i].connected_at_ms = 0;
if (_slots[i].current_outage_started_ms == 0) {
_slots[i].current_outage_started_ms = now ? now : 1;
}
updateSlotTransportDiag(i, static_cast<uint32_t>(millis()));
updateCachedConnectionStatus();
return false;
}
}
_slot_transport_fault_pending[i].store(false, std::memory_order_release);
_slot_health_reset_pending[i].store(false, std::memory_order_release);
_slots[i].transport_health.resetSession();
_slots[i].transport_health.recovery_reason = MQTTTransportHealth::RecoveryReason::None;
updateSlotTransportDiag(i, static_cast<uint32_t>(millis()));
_slots[i].connected = false;
_slots[i].connected_at_ms = 0;
_status_publish_pending[i] = false;
@@ -2040,8 +2104,8 @@ void MQTTBridge::mqttTaskLoop() {
}
}
// Update cached connection status periodically (every 5 seconds)
// This ensures cache stays accurate even if callbacks miss updates
// Refresh the aggregate cache periodically; owner maintenance above
// reconciles adapter state and bounds missed terminal callbacks.
static unsigned long last_slot_status_update = 0;
if (now - last_slot_status_update > 5000) {
updateCachedConnectionStatus();
@@ -2092,6 +2156,7 @@ bool MQTTBridge::ensureSlotClient(int index) {
MQTT_DEBUG_PRINTLN("MQTT%d connected", index + 1);
_slot_attempt_pending[index] = false;
_slots[index].connected = true;
_slot_health_reset_pending[index].store(true, std::memory_order_release);
_slot_force_jwt_mint[index] = false;
// NOTE: reconnect_backoff / max_backoff_failures are NOT reset here.
// A CONNACK alone doesn't prove the link is healthy -- a broker that
@@ -2142,6 +2207,10 @@ bool MQTTBridge::ensureSlotClient(int index) {
updateCachedConnectionStatus();
});
slot.client->onError([this, index](esp_mqtt_error_codes error) {
// The adapter is already disconnected at MQTT_EVENT_ERROR. Do not stop
// its SDK task from inside that task's callback, or release the global
// handshake reservation before the owner proves the SDK has stopped.
_slot_transport_fault_pending[index].store(true, std::memory_order_release);
_slots[index].last_tls_err = error.esp_tls_last_esp_err;
_slots[index].last_tls_stack_err = error.esp_tls_stack_err;
_slots[index].last_sock_errno = error.esp_transport_sock_errno;
@@ -2255,7 +2324,9 @@ bool MQTTBridge::hasPendingSlotConnection() const {
// Callbacks run on esp-mqtt tasks. Time spacing alone cannot establish that
// an earlier handshake has finished, particularly on a slow WiFi link.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
if (_slot_attempt_pending[i].load(std::memory_order_acquire)) return true;
if (_slot_attempt_pending[i].load(std::memory_order_acquire) ||
_slot_transport_fault_pending[i].load(std::memory_order_acquire) ||
_slots[i].transport_health.recovery_reason != MQTTTransportHealth::RecoveryReason::None) return true;
}
return false;
}
@@ -2326,6 +2397,9 @@ bool MQTTBridge::setupSlot(int index) {
slot.client->disconnect();
}
if (slot.client->isStarted()) {
_slot_transport_fault_pending[index].store(true, std::memory_order_release);
slot.transport_health.requestRecovery(MQTTTransportHealth::RecoveryReason::AdapterDisconnected);
updateSlotTransportDiag(index, static_cast<uint32_t>(millis()));
slot.last_reconnect_attempt = millis();
return false;
}
@@ -2558,7 +2632,14 @@ void MQTTBridge::teardownSlot(int index, bool force) {
delay(50);
#endif
}
if (slot.client && !force && slot.client->isStarted()) return;
if (slot.client && !force && slot.client->isStarted()) {
// Even a deliberate reconfigure must retain admission ownership if SDK
// stop failed. The worker will retry cleanup; no second handshake may run.
_slot_transport_fault_pending[index].store(true, std::memory_order_release);
slot.transport_health.requestRecovery(MQTTTransportHealth::RecoveryReason::AdapterDisconnected);
updateSlotTransportDiag(index, static_cast<uint32_t>(millis()));
return;
}
// Invalidate the token but keep the buffer: the client survives teardown and still
// holds this pointer in its config (see MQTTSlot::auth_token).
@@ -2575,6 +2656,11 @@ void MQTTBridge::teardownSlot(int index, bool force) {
slot.last_log_time = 0;
slot.last_deferred_log_ms = 0;
_slot_force_jwt_mint[index] = false;
_slot_transport_fault_pending[index] = false;
_slot_health_reset_pending[index] = false;
slot.transport_health.resetSession();
slot.transport_health.recovery_reason = MQTTTransportHealth::RecoveryReason::None;
updateSlotTransportDiag(index, static_cast<uint32_t>(millis()));
}
esp_err_t MQTTBridge::reconnectSlotClient(int index) {
@@ -2586,6 +2672,7 @@ esp_err_t MQTTBridge::reconnectSlotClient(int index) {
const bool was_pending = _slot_attempt_pending[index];
const bool starting = !slot.client->isStarted();
slot.transport_health.beginAttempt(static_cast<uint32_t>(millis()));
_slot_attempt_pending[index] = true; // callbacks may run before the call returns
esp_err_t result;
if (starting) {
@@ -2597,6 +2684,7 @@ esp_err_t MQTTBridge::reconnectSlotClient(int index) {
if (result != ESP_OK) {
if (MQTTConnectionHealth::clearPendingAfterRejectedRequest(starting, was_pending)) {
_slot_attempt_pending[index] = false;
slot.transport_health.completeAttempt();
}
if (starting) {
slot.start_failures = MQTTConnectionHealth::incrementFailures(slot.start_failures);
@@ -2605,8 +2693,112 @@ esp_err_t MQTTBridge::reconnectSlotClient(int index) {
return result;
}
void MQTTBridge::updateSlotTransportDiag(int index, uint32_t now) {
const MQTTTransportHealth::State& health = _slots[index].transport_health;
_slot_health_recoveries[index].store(health.recoveries, std::memory_order_relaxed);
const auto reason = health.recovery_reason != MQTTTransportHealth::RecoveryReason::None
? health.recovery_reason : health.last_recovery_reason;
_slot_health_reason[index].store(static_cast<uint8_t>(reason), std::memory_order_relaxed);
_slot_health_recovering[index].store(
health.recovery_reason != MQTTTransportHealth::RecoveryReason::None,
std::memory_order_release);
_slot_health_ack_wait_secs[index].store(
health.ack_pending ? (now - health.ack_started_ms) / 1000UL : UINT32_MAX,
std::memory_order_relaxed);
}
bool MQTTBridge::recoverSlotTransport(int index, uint32_t now) {
MQTTSlot& slot = _slots[index];
auto& health = slot.transport_health;
if (!slot.client || health.recovery_reason == MQTTTransportHealth::RecoveryReason::None) return false;
MQTT_DEBUG_PRINTLN("MQTT%d transport recovery: %s", index + 1,
MQTTTransportHealth::reasonLabel(health.recovery_reason));
const bool failed_attempt = MQTTConnectionHealth::disconnectFailedAttempt(
_slot_attempt_pending[index].load(std::memory_order_acquire), slot.connected);
const uint32_t failures_before_stop = slot.connect_failures;
// Retain the client, SDK buffers and credentials. disconnect() closes the
// transport and stops its task, and leaves isStarted() true if SDK stop fails.
// Never vTaskDelete a live SDK task or admit a second TLS handshake on failure.
slot.client->disconnect();
if (slot.client->isStarted()) {
updateSlotTransportDiag(index, static_cast<uint32_t>(millis()));
return false;
}
// SDK stop can consume its socket budget. Space the next handshake from
// completed cleanup, not from the earlier watchdog observation.
now = static_cast<uint32_t>(millis());
// A real DISCONNECTED callback may already have accounted this failure.
// ERROR-only/lost-callback attempts need the same accounting, once.
if (failed_attempt && slot.connect_failures == failures_before_stop) {
slot.connect_failures = MQTTConnectionHealth::incrementFailures(slot.connect_failures);
}
_slot_attempt_pending[index].store(false, std::memory_order_release);
_slot_transport_fault_pending[index].store(false, std::memory_order_release);
_slot_health_reset_pending[index].store(false, std::memory_order_release);
_status_publish_pending[index] = false;
slot.connected = false;
slot.connected_at_ms = 0;
if (slot.current_outage_started_ms == 0) slot.current_outage_started_ms = now ? now : 1;
slot.last_reconnect_attempt = now;
_last_slot_reconnect_ms = now;
health.recovered();
updateSlotTransportDiag(index, now);
updateCachedConnectionStatus();
return true;
}
void MQTTBridge::serviceSlotTransportHealth(uint32_t now) {
if (_stop_requested.load(std::memory_order_acquire)) return;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
MQTTSlot& slot = _slots[i];
auto& health = slot.transport_health;
if (_slot_health_reset_pending[i].exchange(false, std::memory_order_acq_rel)) {
// A late CONNACK does not release an already-required SDK stop. Its
// session clocks may reset, but only proven cleanup clears recovery.
health.resetSession();
}
const bool pending = _slot_attempt_pending[i].load(std::memory_order_acquire);
if (!pending) health.completeAttempt();
if (slot.client) {
health.observeAck(static_cast<uint32_t>(slot.client->getPublishAck()));
if (_slot_transport_fault_pending[i].load(std::memory_order_acquire)) {
health.requestRecovery(MQTTTransportHealth::RecoveryReason::TransportError);
} else if (slot.connected && !slot.client->connected()) {
health.requestRecovery(MQTTTransportHealth::RecoveryReason::AdapterDisconnected);
} else if (pending && health.attemptExpired(now)) {
health.requestRecovery(MQTTTransportHealth::RecoveryReason::HandshakeTimeout);
} else if (slot.connected && health.ackExpired(now)) {
health.requestRecovery(MQTTTransportHealth::RecoveryReason::AckTimeout);
}
if (!slot.connected && !pending) health.resetSession();
}
if (health.recovery_reason != MQTTTransportHealth::RecoveryReason::None) {
// Stop advertising a healthy link immediately, including while SDK
// cleanup is still failing. Admission is held by the recovery state,
// independently of this observable connection flag.
slot.connected = false;
slot.connected_at_ms = 0;
if (slot.current_outage_started_ms == 0) slot.current_outage_started_ms = now ? now : 1;
}
updateSlotTransportDiag(i, now);
}
updateCachedConnectionStatus();
// Close no more than one SDK transport per pass. A failed stop keeps its
// recovery reservation and blocks all new handshakes on subsequent passes.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
if (_slots[i].client && _slots[i].transport_health.recovery_reason != MQTTTransportHealth::RecoveryReason::None) {
recoverSlotTransport(i, now);
break;
}
}
}
void MQTTBridge::maintainSlotConnections() {
if (_stop_requested.load(std::memory_order_acquire)) return;
serviceSlotTransportHealth(static_cast<uint32_t>(millis()));
if (!_identity) return;
// Check WiFi status first
@@ -3003,6 +3195,14 @@ bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload
if (_stop_requested.load(std::memory_order_acquire)) return false;
if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return false;
MQTTSlot& slot = _slots[index];
// On-connect status is processed before maintenance. Consume this new-
// session latch BEFORE enqueueing it, or maintenance would erase its ACK
// watch immediately afterwards. Only this owner task mutates health state.
if (_slot_health_reset_pending[index].exchange(false, std::memory_order_acq_rel)) {
slot.transport_health.resetSession();
}
if (slot.transport_health.recovery_reason != MQTTTransportHealth::RecoveryReason::None ||
_slot_transport_fault_pending[index].load(std::memory_order_acquire)) return false;
if (!slot.client || !slot.connected) {
unsigned long now = millis();
if (now - slot.last_log_time > SLOT_LOG_INTERVAL) {
@@ -3020,16 +3220,23 @@ bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload
// write bypasses that drain ceiling entirely and does not store in the outbox. It can
// block the (Core-0, prio-1) MQTT task on a stalled socket, but only up to
// network_timeout_ms (configured in optimizeMqttClientConfig); mesh RX (Core 1) and the
// WiFi/TCP stack (higher-prio system tasks) are unaffected, and a failed write flips
// the slot to disconnected so subsequent packets skip it.
// WiFi/TCP stack (higher-prio system tasks) are unaffected. A failed write reserves
// owner-task transport recovery, so subsequent packets skip the failed slot.
// - QoS 1 (low-rate retained status): async, so it keeps the durable outbox + retransmit.
//
// Return convention: QoS 0 sync publish returns msg_id == 0 on success (no PUBACK
// tracking). Negative values (-1 write/failure) are the only actual failures; the queue
// retry/drop path below handles them.
bool async = (qos > 0);
// Take the actual broker ACK counter before the SDK call: a fast PUBACK can
// arrive before enqueue returns. A post-call snapshot would lose that proof.
const uint32_t ack_before = static_cast<uint32_t>(slot.client->getPublishAck());
int result = slot.client->publish(topic, qos, retained, payload, (int)payload_len, async);
if (result < 0) {
if (!async) {
slot.transport_health.requestRecovery(MQTTTransportHealth::RecoveryReason::PublishFailure);
updateSlotTransportDiag(index, static_cast<uint32_t>(millis()));
}
// QoS0 packet/raw publishes are best-effort and may be retried from the
// bridge queue; avoid logging transient first-attempt failures here.
if (qos > 0) {
@@ -3042,6 +3249,12 @@ bool MQTTBridge::publishToSlot(int index, const char* topic, const char* payload
}
return false;
}
if (qos > 0) {
slot.transport_health.acceptedQos1(static_cast<uint32_t>(millis()), ack_before);
// Also reflect an ACK delivered during enqueue, not just on the next tick.
slot.transport_health.observeAck(static_cast<uint32_t>(slot.client->getPublishAck()));
updateSlotTransportDiag(index, static_cast<uint32_t>(millis()));
}
return true;
}
@@ -3189,8 +3402,7 @@ void MQTTBridge::publishStatusToSlot(int index) {
// on-connect status must not force retain=true. Custom slots default to
// non-retained here too, keeping both status paths consistent.
bool use_retain = slot.preset ? slot.preset->allow_retain : false;
int result = slot.client->publish(status_topic, 1, use_retain, json_buffer, len);
if (result <= 0) {
if (!publishToSlot(index, status_topic, json_buffer, static_cast<size_t>(len), use_retain, 1)) {
MQTT_DEBUG_PRINTLN("MQTT%d status publish failed", index + 1);
}
}
@@ -3453,11 +3665,28 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) {
s_wifi_connected_at = 0;
// Disconnect all slot clients when WiFi drops
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].client && _slots[i].connected) {
// Include pending handshakes, not just sessions that reached CONNACK.
if (_slots[i].client && _slots[i].client->isStarted()) {
_slot_transport_fault_pending[i].store(true, std::memory_order_release);
_slot_attempt_pending[i] = false;
_slots[i].client->disconnect();
_slots[i].connected = false;
_slots[i].connected_at_ms = 0;
if (_slots[i].client->isStarted()) {
_slots[i].transport_health.requestRecovery(MQTTTransportHealth::RecoveryReason::AdapterDisconnected);
if (_slots[i].current_outage_started_ms == 0) {
_slots[i].current_outage_started_ms = now ? now : 1;
}
} else {
_slot_transport_fault_pending[i].store(false, std::memory_order_release);
_slot_health_reset_pending[i].store(false, std::memory_order_release);
_slots[i].transport_health.resetSession();
_slots[i].transport_health.recovery_reason = MQTTTransportHealth::RecoveryReason::None;
}
updateSlotTransportDiag(i, static_cast<uint32_t>(millis()));
}
}
updateCachedConnectionStatus();
} else if (snapshot.down) {
// Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6),
// exercised by host tests. Behavior is unchanged: both the link-down
+15
View File
@@ -12,6 +12,7 @@
#include "helpers/JWTHelper.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTConnectionHealth.h"
#include "helpers/MQTTTransportHealth.h"
#include "helpers/MQTTPayloadBuilder.h"
#include "helpers/MQTTPacketFilter.h"
#include "helpers/MQTTPresets.h"
@@ -158,6 +159,10 @@ private:
// DISCONNECTED (including a failed initial attempt). first_disconnect_time is intentionally separate
// so the existing 'mqttN.diag' "first_disc" semantics don't change.
unsigned long current_outage_started_ms;
// Only the bridge worker mutates this state; SDK callbacks raise atomic
// latches instead. Retained across reconnects to expose recovery history.
MQTTTransportHealth::State transport_health;
};
MQTTSlot _slots[RUNTIME_MQTT_SLOTS];
@@ -260,6 +265,13 @@ private:
// Set before starting/reconnecting, cleared by connect/failed-attempt/stop.
// Must be set before the SDK call because its event task can callback at once.
std::atomic<bool> _slot_attempt_pending[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _slot_health_reset_pending[RUNTIME_MQTT_SLOTS];
// Read-only CLI snapshots: never read the owner-only State across tasks.
std::atomic<uint32_t> _slot_health_recoveries[RUNTIME_MQTT_SLOTS];
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];
// Pending on-connect status publish: set from the onConnect callback (which
// runs on the esp-mqtt event task, NOT this bridge task), consumed by the MQTT
@@ -509,6 +521,9 @@ private:
bool canStartSlotConnection(int index) const;
void teardownSlot(int index, bool force = false);
esp_err_t reconnectSlotClient(int index);
bool recoverSlotTransport(int index, uint32_t now);
void serviceSlotTransportHealth(uint32_t now);
void updateSlotTransportDiag(int index, uint32_t now);
void maintainSlotConnections(); // Maintain all slot connections (token renewal, reconnect)
void maintainSlotConnection(int index, unsigned long now_millis, unsigned long current_time, bool time_synced, bool& reconnect_attempted, bool& teardown_attempted);
bool createSlotAuthToken(int index); // Create/renew JWT token for a slot
+1
View File
@@ -167,6 +167,7 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries |
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover |
| `test_mqtt_transport_health.py`, `test_mqtt_handshake_scheduler.py` | `src/helpers/MQTTTransportHealth.h`, production `MQTTBridge` methods | oldest outstanding QoS 1 acknowledgment deadline; quiet-session safety; lost/error-only callbacks; serialized stop-before-reconnect recovery; multi-hour scheduling and timer rollover |
| `test_wifi_reconnect_policy` | `src/helpers/WiFiReconnectPolicy.h` | five-minute forced reconnect cadence, connection resets, duplicate disconnect observations, and 32-bit `millis()` rollover |
| `test_radio_liveness` | `src/helpers/RadioLivenessTracker.h`, `src/helpers/radiolib/RxBoostedGainDefaults.h` | staged radio recovery, activity reset and rollover handling; target-specific RX boosted-gain defaults and SX126x precedence |
| `test_lr1110_rx_recovery` | `src/helpers/radiolib/LR1110RxRecovery.h` | LR1110 four-byte RX-buffer shift signature; captured and accumulated shifts; ordinary/scoped packet exclusions |
+10 -1
View File
@@ -6,6 +6,7 @@
#include <vector>
#include "helpers/AlertFaultPolicy.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTTransportHealth.h"
#include "helpers/WiFiPowerSave.h"
#include "helpers/WiFiNetworkPowerPolicy.h"
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
@@ -90,8 +91,16 @@ struct MQTTBridge{
struct{bool canonical_wifi=true;}_node_info;
struct{uint8_t wifi_power_save=1;}prefs,*_obs=&prefs;
struct Client{void disconnect(){}bool isStarted()const{return false;}};
struct Slot{Client* client=nullptr;bool connected=false;}_slots[RUNTIME_MQTT_SLOTS];
struct Slot{
Client* client=nullptr;bool connected=false;
uint32_t connected_at_ms=0,current_outage_started_ms=0;
MQTTTransportHealth::State transport_health;
}_slots[RUNTIME_MQTT_SLOTS];
bool _slot_attempt_pending[RUNTIME_MQTT_SLOTS]={};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS]={};
std::atomic<bool> _slot_health_reset_pending[RUNTIME_MQTT_SLOTS]={};
void updateSlotTransportDiag(int,uint32_t){}
void updateCachedConnectionStatus(){}
std::vector<std::string> cleanup;
void cancelNtpRefresh(){cleanup.push_back("ntp");}
void teardownSlot(int index){cleanup.push_back("slot"+std::to_string(index));}
+3
View File
@@ -15,6 +15,7 @@ POLICY_PREAMBLE = r'''
#include <cstddef>
#include <cstdint>
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTTransportHealth.h"
namespace Admission = MQTTConnectionAdmission;
using MemoryState = Admission::ClientMemoryState;
'''
@@ -65,8 +66,10 @@ struct MQTTBridge {
const Preset* preset = nullptr;
char broker_uri[128] = {}, host[64] = "broker";
uint16_t port = 8883;
MQTTTransportHealth::State transport_health;
} _slots[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
bool hasPendingSlotConnection() const;
bool canStartSlotConnection(int index) const;
};
+6
View File
@@ -27,6 +27,7 @@ HARNESS = r'''
#include <functional>
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/MQTTConnectionPolicy.h>
#include <helpers/MQTTTransportHealth.h>
#define private public
#include "PsychicMqttClient.h"
#undef private
@@ -100,6 +101,7 @@ struct MQTTBridge {
PsychicMqttClient* client = nullptr;
bool enabled = true, connected = false, circuit_breaker_tripped = false;
uint32_t connected_at_ms = 0, last_reconnect_attempt = 0;
uint32_t current_outage_started_ms = 0;
uint8_t reconnect_backoff = 0;
bool initial_connect_done = true;
char* auth_token = nullptr;
@@ -107,11 +109,14 @@ struct MQTTBridge {
uint32_t token_expires_at = 1, last_token_renewal = 1;
uint32_t last_log_time = 1, last_deferred_log_ms = 1;
uint8_t max_backoff_failures = 0;
MQTTTransportHealth::State transport_health;
} _slots[RUNTIME_MQTT_SLOTS];
using MQTTSlot = Slot;
mesh::wifi::NetworkPowerPolicy _network_power_policy;
std::atomic<bool> _network_suspended{false}, _fresh_ntp_this_boot{false};
bool _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_reset_pending[RUNTIME_MQTT_SLOTS] = {};
bool _status_publish_pending[RUNTIME_MQTT_SLOTS] = {};
bool _slot_force_jwt_mint[RUNTIME_MQTT_SLOTS] = {};
uint32_t _network_publish_acks[RUNTIME_MQTT_SLOTS] = {};
@@ -127,6 +132,7 @@ struct MQTTBridge {
Board* _board = nullptr;
void cancelNtpRefresh() {}
void updateCachedConnectionStatus() {}
void updateSlotTransportDiag(int, uint32_t) {}
void initializeWiFiInTask() {}
void processPacketQueue() { ++queues; }
void teardownSlot(int index, bool force = false);
+499 -7
View File
@@ -21,11 +21,14 @@ PREAMBLE = r'''
#include <cstdio>
#include <cstring>
#include <functional>
#include <new>
#include <sys/time.h>
#include <vector>
#include "helpers/AlertFaultPolicy.h"
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTConnectionHealth.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTTransportHealth.h"
#include "helpers/WiFiPowerSave.h"
#define ESP_PLATFORM 1
#define MQTT_DEBUG_PRINTLN(...) ((void)0)
@@ -43,6 +46,11 @@ bool enforceStationChannel() { return true; }
bool mqttStationWiFiMutationAllowed() { return true; }
void esp_wifi_set_ps(wifi_ps_type_t) {}
constexpr int MQTT_AUTH_JWT = 1;
constexpr int MQTT_ERROR_TYPE_CONNECTION_REFUSED = 5;
struct esp_mqtt_error_codes {
int esp_tls_last_esp_err = 0, esp_tls_stack_err = 0;
int esp_transport_sock_errno = 0, error_type = 0, connect_return_code = 0;
};
constexpr uint32_t MALLOC_CAP_INTERNAL = 1, MALLOC_CAP_DMA = 2;
static uint32_t clock_ms = 1000;
static unsigned long wall_seconds = 1800000000;
@@ -59,7 +67,8 @@ struct {
} WiFi;
struct { size_t size = 2097152; size_t getPsramSize() const { return size; } } ESP;
size_t heap_caps_get_free_size(uint32_t caps) {
assert(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); return dma_free;
assert(caps == MALLOC_CAP_INTERNAL || caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA));
return dma_free;
}
size_t heap_caps_get_largest_free_block(uint32_t caps) {
assert(caps == (MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA)); return dma_largest;
@@ -70,17 +79,61 @@ struct Preset {
int auth_type = 0;
unsigned long token_lifetime = 3300;
bool enforce_exp = false;
bool allow_retain = false;
};
bool mqttPresetEnforcesTokenExp(const Preset* preset) {
return preset && preset->enforce_exp;
}
namespace MQTTMessageBuilder {
void formatIsoTimestampForMqtt(long, long, const char*, char* buffer, size_t size) {
snprintf(buffer, size, "2026-10-14T00:00:00+00:00");
}
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...) {
assert(size > 2);
strcpy(buffer, "{}");
return 2;
}
}
void formatRadioInfo(float, float, int, int, char* buffer, size_t size) {
snprintf(buffer, size, "909.5,500,5,5");
}
struct PsychicMqttClient {
bool initialized = false, started = false, live = false, model_task_heap = false;
bool stop_succeeds = true;
int starts = 0, reconnects = 0, bounces = 0, credentials = 0, stops = 0;
uint32_t acknowledgments = 0;
int publish_result = 0, publishes = 0;
bool immediate_ack = false;
std::function<void(bool)> connected_callback, disconnected_callback;
std::function<void(esp_mqtt_error_codes)> error_callback;
std::function<void()> disconnected;
bool isInitialized() const { return initialized; }
bool isStarted() const { return started; }
bool connected() const { return live; }
uint32_t getPublishAck() const { return acknowledgments; }
void setAutoReconnect(bool enabled) { assert(!enabled); }
void onConnect(std::function<void(bool)> callback) { connected_callback = callback; }
void onDisconnect(std::function<void(bool)> callback) { disconnected_callback = callback; }
void onError(std::function<void(esp_mqtt_error_codes)> callback) { error_callback = callback; }
void errorWithoutDisconnect() {
live = false; // The real adapter does this before calling onError.
assert(error_callback);
error_callback(esp_mqtt_error_codes{});
}
void connectEvent() {
live = true;
assert(connected_callback);
connected_callback(false);
}
int publish(const char*, int qos, bool, const char*, int, bool async) {
assert(async == (qos > 0));
++publishes;
if (immediate_ack && qos > 0 && publish_result >= 0) ++acknowledgments;
return publish_result;
}
int connect() {
++starts;
if (model_task_heap) {
@@ -93,9 +146,11 @@ struct PsychicMqttClient {
int reconnect() { ++reconnects; return ESP_OK; }
void disconnect() {
++stops;
if (!stop_succeeds) return;
if (model_task_heap && started) dma_free += 6536;
started = live = false;
if (disconnected) disconnected();
if (disconnected_callback) disconnected_callback(false);
}
void softDisconnect() {
++bounces; live = false;
@@ -117,12 +172,26 @@ struct MQTTBridge {
uint8_t reconnect_backoff = 0, max_backoff_failures = 0;
bool circuit_breaker_tripped = false;
uint32_t start_failures = 0;
uint32_t connect_failures = 0, disconnect_count = 0;
unsigned long first_disconnect_time = 0, current_outage_started_ms = 0;
unsigned long last_log_time = 0;
int32_t last_tls_err = 0, last_tls_stack_err = 0;
int last_sock_errno = 0;
unsigned long last_error_time = 0;
MQTTTransportHealth::State transport_health;
} _slots[RUNTIME_MQTT_SLOTS];
PsychicMqttClient clients[RUNTIME_MQTT_SLOTS];
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};
std::atomic<bool> _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_reset_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint32_t> _slot_health_recoveries[RUNTIME_MQTT_SLOTS] = {};
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> _stop_requested{false}, _ntp_synced{true};
bool _slot_force_jwt_mint[RUNTIME_MQTT_SLOTS] = {};
void* _identity = this;
@@ -131,7 +200,11 @@ struct MQTTBridge {
wl_status_t _last_wifi_status = WL_CONNECTED;
unsigned long _last_wifi_check = 0, _last_wifi_reconnect_attempt = 0;
uint8_t _wifi_reconnect_backoff_attempt = 0, _wifi_power_save = 1;
struct { bool canonical_wifi = false; } _node_info;
struct {
bool canonical_wifi = false;
float *freq = nullptr, *bw = nullptr;
int *sf = nullptr, *cr = nullptr;
} _node_info;
struct Observer { uint8_t wifi_power_save = 1; } observer;
Observer* _obs = &observer;
AlertFaultPolicy::OutageSnapshot _wifi_outage{};
@@ -142,6 +215,39 @@ struct MQTTBridge {
void beginWiFiStation() {}
const char* _jwt_username = "jwt";
static const unsigned long SLOT_SETUP_RETRY_INTERVAL = 60000;
static const unsigned long SLOT_LOG_INTERVAL = 60000;
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";
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 clock_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* output, size_t size) { snprintf(output, size, "test"); }
int repeatStatus() const { return 0; }
int collectConnHealth() const { return 0; }
bool buildTopicForSlot(int, int, char* output, size_t size) {
snprintf(output, size, "status");
return true;
}
std::vector<int> setups;
bool isSlotReady(int index) { return ready[index]; }
bool setupSlot(int index) {
@@ -162,7 +268,14 @@ struct MQTTBridge {
void complete(int index) {
_slot_attempt_pending[index].store(false, std::memory_order_release);
_slots[index].connected = clients[index].live = true;
if (_slots[index].client) _slots[index].client->live = true;
_slots[index].connected_at_ms = clock_ms;
_slot_health_reset_pending[index] = true;
}
void updateCachedConnectionStatus() {}
bool ensureSlotClient(int index);
bool publishToSlot(int, const char*, const char*, size_t, bool, uint8_t);
void publishStatusToSlot(int);
void only(int index) {
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) _slots[i].enabled = i == index;
}
@@ -173,6 +286,9 @@ struct MQTTBridge {
esp_err_t reconnectSlotClient(int index);
unsigned long slotTokenLifetime(int index) const;
void maintainSlotConnections();
bool recoverSlotTransport(int index, uint32_t now);
void serviceSlotTransportHealth(uint32_t now);
void updateSlotTransportDiag(int index, uint32_t now);
bool handleWiFiConnection(unsigned long);
void maintainSlotConnection(int, unsigned long, unsigned long, bool, bool&, bool&);
};
@@ -188,25 +304,36 @@ class MqttHandshakeSchedulerTest(unittest.TestCase):
"bool MQTTBridge::hasPendingSlotConnection() const",
"bool MQTTBridge::canStartSlotConnection(int index) const",
"esp_err_t MQTTBridge::reconnectSlotClient(int index)",
"bool MQTTBridge::recoverSlotTransport(",
"void MQTTBridge::serviceSlotTransportHealth(",
"void MQTTBridge::updateSlotTransportDiag(",
"bool MQTTBridge::ensureSlotClient(",
"bool MQTTBridge::publishToSlot(",
"void MQTTBridge::publishStatusToSlot(",
"unsigned long MQTTBridge::slotTokenLifetime(int index) const",
"bool MQTTBridge::handleWiFiConnection(unsigned long now)",
"void MQTTBridge::maintainSlotConnections()",
"void MQTTBridge::maintainSlotConnection("))
def compile_run(self, main):
def compile_run(self, main, fixture=None, expect_rejection=False):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-scheduler-") as temporary:
work = Path(temporary)
source, binary = work / "scheduler.cpp", work / "scheduler"
source.write_text(self.fixture() + main, encoding="utf-8")
source.write_text((self.fixture() if fixture is None else fixture) + main,
encoding="utf-8")
result = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-I", str(ROOT / "src"),
str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], cwd=work, capture_output=True,
text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if expect_rejection:
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("Assertion", result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_first_start_is_immediate_and_delayed_callback_serializes_following_slots(self):
self.compile_run(r'''
@@ -220,7 +347,7 @@ int main() {
// still pending. Ordinary maintenance must not launch another SDK request.
clock_ms = 16000;
bridge.maintainSlotConnections();
clock_ms = 3600000;
clock_ms = 60000;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>{0});
assert(bridge.clients[0].starts == 1 && bridge.clients[0].reconnects == 0);
@@ -419,7 +546,7 @@ int main() {
bridge.maintainSlotConnections();
assert(bridge.clients[0].starts == 1 && dma_free == 35840);
assert(bridge.hasPendingSlotConnection());
clock_ms += 3600000;
clock_ms += 60000;
bridge.maintainSlotConnections();
assert(bridge.clients[1].starts == 0 && bridge.clients[2].starts == 0);
bridge.complete(0);
@@ -450,6 +577,371 @@ int main() {
}
''')
def test_missing_handshake_callback_times_out_only_after_sdk_stop(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0);
clock_ms = 1000;
bridge.maintainSlotConnections();
assert(bridge.clients[0].starts == 1 && bridge._slot_attempt_pending[0]);
clock_ms = 120999;
bridge.maintainSlotConnections();
assert(bridge.clients[0].stops == 0 && bridge._slot_attempt_pending[0]);
clock_ms = 121000;
bridge.maintainSlotConnections();
auto& slot = bridge._slots[0];
assert(bridge.clients[0].stops == 1 && !bridge.clients[0].isStarted());
assert(!bridge._slot_attempt_pending[0] && !bridge.hasPendingSlotConnection());
assert(slot.transport_health.recoveries == 1 && slot.connect_failures == 1);
assert(slot.transport_health.last_recovery_reason ==
MQTTTransportHealth::RecoveryReason::HandshakeTimeout);
assert(slot.last_reconnect_attempt == clock_ms);
// Cleanup doesn't start another SDK task in the same maintenance pass.
assert(bridge.clients[0].starts == 1);
clock_ms += 14999;
bridge.maintainSlotConnections();
assert(bridge.clients[0].starts == 1);
++clock_ms;
bridge.maintainSlotConnections();
assert(bridge.clients[0].starts == 2 && bridge._slot_attempt_pending[0]);
assert(slot.reconnect_backoff == 1);
}
''')
def test_error_callback_without_disconnect_defers_cleanup_and_preserves_reservation(self):
self.compile_run(r'''
int main() {
for (bool established : {false, true}) {
MQTTBridge bridge;
bridge.only(0);
clock_ms = 1000;
assert(bridge.ensureSlotClient(0));
auto& slot = bridge._slots[0];
slot.initial_connect_done = true;
auto* client = slot.client;
assert(bridge.reconnectSlotClient(0) == ESP_OK);
if (established) client->connectEvent();
client->errorWithoutDisconnect();
// The actual production callback latches work. It cannot stop its own
// esp-mqtt task or release a still-running handshake from that callback.
assert(client->stops == 0 && client->isStarted());
assert(bridge._slot_transport_fault_pending[0]);
assert(bridge.hasPendingSlotConnection() && !bridge.canStartSlotConnection(1));
client->stop_succeeds = false;
bridge.maintainSlotConnections();
assert(client->stops == 1 && client->isStarted());
assert(bridge._slot_transport_fault_pending[0]);
assert(bridge._slot_attempt_pending[0] == !established);
assert(bridge.hasPendingSlotConnection() && !bridge.canStartSlotConnection(1));
assert(slot.transport_health.recoveries == 0 && client->starts == 1);
// A later successful stop proves ownership ended, then recovery may admit
// another handshake. Failure accounting remains exactly once.
client->stop_succeeds = true;
bridge.maintainSlotConnections();
assert(client->stops == 2 && !client->isStarted());
assert(!bridge.hasPendingSlotConnection() && !bridge._slot_transport_fault_pending[0]);
assert(slot.transport_health.recoveries == 1);
assert(slot.connect_failures == (established ? 0u : 1u));
assert(slot.transport_health.last_recovery_reason ==
MQTTTransportHealth::RecoveryReason::TransportError);
assert(client->starts == 1); // completed cleanup starts the 15-second gap
delete client;
slot.client = nullptr;
}
}
''')
def test_failed_stop_after_lost_callback_blocks_every_new_handshake(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
clock_ms = 1000;
bridge.maintainSlotConnections();
bridge.clients[0].stop_succeeds = false;
clock_ms += MQTTTransportHealth::kAttemptTimeoutMs;
for (int pass = 0; pass < 3; ++pass) {
bridge.maintainSlotConnections();
assert(bridge.clients[0].stops == pass + 1 && bridge.clients[0].isStarted());
assert(bridge._slot_attempt_pending[0] && bridge.hasPendingSlotConnection());
assert(bridge._slots[0].transport_health.recoveries == 0);
assert(bridge.setups == std::vector<int>{0});
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i)
assert(!bridge.canStartSlotConnection(i));
clock_ms += 15000;
}
bridge.clients[0].stop_succeeds = true;
bridge.maintainSlotConnections();
assert(!bridge.clients[0].isStarted() && !bridge.hasPendingSlotConnection());
assert(bridge._slots[0].transport_health.recoveries == 1);
assert(bridge.setups == std::vector<int>{0});
}
''')
def test_three_brokers_run_beyond_three_hours_and_only_missing_ack_recovers(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
clock_ms = 1000;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; ++i) {
bridge._slots[i].enabled = i < 3;
if (i >= 3) continue;
auto& slot = bridge._slots[i];
slot.client = &bridge.clients[i];
slot.initial_connect_done = true;
slot.client->initialized = slot.client->started = true;
bridge.complete(i);
slot.client->immediate_ack = true;
}
// Two status publishers get real ACK-counter progress; the third is quiet
// (for example, its broker does not accept status topics). Neither condition
// is a transport stall, irrespective of connection age or packet activity.
for (int minute = 0; minute <= 180; ++minute) {
assert(bridge.publishToSlot(0, "status", "{}", 2, true, 1));
assert(bridge.publishToSlot(1, "status", "{}", 2, true, 1));
bridge.maintainSlotConnections();
for (int i = 0; i < 3; ++i) assert(bridge.clients[i].stops == 0);
clock_ms += 60000;
wall_seconds += 60;
}
assert(clock_ms > 3 * 60 * 60 * 1000);
bridge.clients[0].immediate_ack = false;
const uint32_t stalled_at = clock_ms;
assert(bridge.publishToSlot(0, "status", "{}", 2, true, 1));
for (uint32_t elapsed = 0; elapsed < 120000; elapsed += 30000) {
clock_ms = stalled_at + elapsed;
assert(bridge.publishToSlot(0, "status", "{}", 2, true, 1));
assert(bridge.publishToSlot(1, "status", "{}", 2, true, 1));
bridge.maintainSlotConnections();
assert(bridge.clients[0].stops == 0);
assert(bridge._slots[0].transport_health.ack_started_ms == stalled_at);
}
clock_ms = stalled_at + 120000;
bridge.maintainSlotConnections();
assert(bridge.clients[0].stops == 1 && !bridge._slots[0].connected);
assert(bridge._slots[0].transport_health.recoveries == 1);
assert(bridge._slots[0].transport_health.last_recovery_reason ==
MQTTTransportHealth::RecoveryReason::AckTimeout);
for (int i = 1; i < 3; ++i) {
assert(bridge.clients[i].stops == 0 && bridge._slots[i].connected);
assert(bridge._slots[i].transport_health.recoveries == 0);
}
assert(bridge._slot_health_recoveries[0] == 1);
assert(!bridge._slot_health_recovering[0]);
}
''')
def test_on_connect_status_watch_survives_maintenance_and_ack_clears_it(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); clock_ms = 1000;
assert(bridge.ensureSlotClient(0));
auto& slot = bridge._slots[0];
slot.initial_connect_done = true;
assert(bridge.reconnectSlotClient(0) == ESP_OK);
slot.client->connectEvent();
assert(bridge._slot_health_reset_pending[0] && bridge._status_publish_pending[0]);
// The real worker consumes an on-connect status before it calls maintenance.
bridge.publishStatusToSlot(0);
assert(slot.client->publishes == 1);
assert(!bridge._slot_health_reset_pending[0] && slot.transport_health.ack_pending);
bridge.maintainSlotConnections();
assert(slot.transport_health.ack_pending);
clock_ms += 119999;
++slot.client->acknowledgments;
bridge.maintainSlotConnections();
assert(!slot.transport_health.ack_pending && slot.client->stops == 0);
assert(bridge._slot_health_ack_wait_secs[0] == UINT32_MAX);
slot.client->immediate_ack = true;
assert(bridge.publishToSlot(0, "status", "{}", 2, true, 1));
assert(!slot.transport_health.ack_pending); // ACK arrived inside enqueue
clock_ms += 3600000;
bridge.maintainSlotConnections();
assert(slot.client->stops == 0);
delete slot.client;
slot.client = nullptr;
}
''')
def test_negative_qos0_write_without_disconnect_requests_owner_recovery(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); clock_ms = 1000;
auto& slot = bridge._slots[0];
slot.client = &bridge.clients[0];
slot.client->initialized = slot.client->started = true;
slot.initial_connect_done = true;
bridge.complete(0);
slot.client->publish_result = -1;
assert(!bridge.publishToSlot(0, "packets", "{}", 2, false, 0));
assert(slot.client->stops == 0 && slot.connected && slot.client->connected());
assert(slot.transport_health.recovery_reason ==
MQTTTransportHealth::RecoveryReason::PublishFailure);
assert(bridge.hasPendingSlotConnection());
assert(!bridge.publishToSlot(0, "packets", "{}", 2, false, 0));
assert(slot.client->publishes == 1); // don't repeatedly hit the broken socket
bridge.maintainSlotConnections();
assert(slot.client->stops == 1 && !slot.connected && !slot.client->isStarted());
assert(slot.transport_health.recoveries == 1);
assert(slot.transport_health.last_recovery_reason ==
MQTTTransportHealth::RecoveryReason::PublishFailure);
}
''')
def test_handshake_and_ack_recovery_are_millis_wrap_safe(self):
self.compile_run(r'''
int main() {
for (bool awaiting_ack : {false, true}) {
MQTTBridge bridge;
bridge.only(0); clock_ms = UINT32_MAX - 60000;
bridge.maintainSlotConnections();
auto& slot = bridge._slots[0];
if (awaiting_ack) {
bridge.complete(0);
assert(bridge.publishToSlot(0, "status", "{}", 2, true, 1));
}
clock_ms += 119999;
bridge.maintainSlotConnections();
assert(slot.client->stops == 0);
++clock_ms;
bridge.maintainSlotConnections();
assert(slot.client->stops == 1 && slot.transport_health.recoveries == 1);
assert(slot.transport_health.last_recovery_reason == (awaiting_ack
? MQTTTransportHealth::RecoveryReason::AckTimeout
: MQTTTransportHealth::RecoveryReason::HandshakeTimeout));
}
}
''')
def test_recovery_preserves_backoff_and_restart_memory_admission(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); clock_ms = 70000;
auto& slot = bridge._slots[0];
slot.client = &bridge.clients[0];
slot.client->initialized = slot.client->started = true;
slot.initial_connect_done = true;
bridge.complete(0);
slot.reconnect_backoff = 3;
slot.max_backoff_failures = 1;
bridge._slot_transport_fault_pending[0] = true;
bridge.maintainSlotConnections();
assert(slot.client->stops == 1 && slot.reconnect_backoff == 3);
assert(slot.max_backoff_failures == 1 && slot.connect_failures == 0);
clock_ms += 120000;
dma_free = 24575; dma_largest = 20000;
bridge.maintainSlotConnections();
assert(slot.client->starts == 0 && slot.reconnect_backoff == 3);
assert(slot.last_reconnect_attempt == 70000 && slot.start_failures == 0);
dma_free = 24576; dma_largest = 8191;
bridge.maintainSlotConnections();
assert(slot.client->starts == 0 && slot.reconnect_backoff == 3);
dma_largest = 8192;
bridge.maintainSlotConnections();
assert(slot.client->starts == 1 && bridge._slot_attempt_pending[0]);
assert(slot.reconnect_backoff == 4 && slot.max_backoff_failures == 1);
}
''')
def test_late_connected_callback_cannot_release_failed_stop_reservation(self):
self.compile_run(r'''
int main() {
for (bool publish_before_maintenance : {false, true}) {
MQTTBridge bridge;
bridge.only(0); clock_ms = 1000;
assert(bridge.ensureSlotClient(0));
auto& slot = bridge._slots[0];
slot.initial_connect_done = true;
auto* client = slot.client;
assert(bridge.reconnectSlotClient(0) == ESP_OK);
client->connectEvent();
client->errorWithoutDisconnect();
client->stop_succeeds = false;
bridge.maintainSlotConnections();
assert(client->isStarted() && client->stops == 1);
assert(slot.transport_health.recovery_reason != MQTTTransportHealth::RecoveryReason::None);
// An in-flight SDK event arrives after stop failed. CONNACK may clear the
// attempt bit and set a new-session latch, but it cannot prove task join.
client->connectEvent();
assert(!bridge._slot_attempt_pending[0] && bridge._slot_health_reset_pending[0]);
assert(bridge.hasPendingSlotConnection() && !bridge.canStartSlotConnection(1));
if (publish_before_maintenance) {
assert(!bridge.publishToSlot(0, "status", "{}", 2, true, 1));
assert(client->publishes == 0 && bridge.hasPendingSlotConnection());
}
bridge.maintainSlotConnections();
assert(client->stops == 2 && client->isStarted());
assert(bridge.hasPendingSlotConnection() && !bridge.canStartSlotConnection(1));
assert(client->starts == 1 && bridge.setups.empty());
assert(slot.transport_health.recoveries == 0);
client->stop_succeeds = true;
bridge.maintainSlotConnections();
assert(!client->isStarted() && !bridge.hasPendingSlotConnection());
assert(slot.transport_health.recoveries == 1 && client->starts == 1);
delete client;
slot.client = nullptr;
}
}
''')
def test_wifi_loss_failed_stop_and_reconnect_cannot_release_sdk_reservation(self):
self.compile_run(r'''
int main() {
for (bool established : {false, true}) {
MQTTBridge bridge;
bridge.only(0); clock_ms = 70000; WiFi.state = WL_CONNECTED;
assert(bridge.ensureSlotClient(0));
auto& slot = bridge._slots[0];
slot.initial_connect_done = true;
auto* client = slot.client;
assert(bridge.reconnectSlotClient(0) == ESP_OK);
if (established) client->connectEvent();
client->stop_succeeds = false;
WiFi.state = WL_DISCONNECTED;
assert(!bridge.handleWiFiConnection(clock_ms));
assert(client->isStarted() && client->stops == 1 && !slot.connected);
assert(bridge._slot_transport_fault_pending[0] && bridge.hasPendingSlotConnection());
assert(!bridge.canStartSlotConnection(1));
clock_ms += 11000;
WiFi.state = WL_CONNECTED;
assert(bridge.handleWiFiConnection(clock_ms));
client->connectEvent(); // late SDK callback after WiFi recovered
assert(bridge.hasPendingSlotConnection());
bridge.maintainSlotConnections();
assert(client->isStarted() && client->stops == 2 && !slot.connected);
assert(bridge.hasPendingSlotConnection() && !bridge.canStartSlotConnection(1));
assert(client->starts == 1 && bridge.setups.empty());
client->stop_succeeds = true;
bridge.maintainSlotConnections();
assert(!client->isStarted() && !bridge.hasPendingSlotConnection());
assert(slot.transport_health.recoveries == 1 && client->starts == 1);
delete client;
slot.client = nullptr;
}
}
''')
def test_pre_fix_error_callback_without_owner_signal_fails_regression(self):
source = self.fixture()
old_callback = source.replace(
"_slot_transport_fault_pending[index].store(true, std::memory_order_release);",
"/* pre-fix onError had no owner-task recovery signal */", 1)
self.assertNotEqual(source, old_callback)
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); clock_ms = 1000;
assert(bridge.ensureSlotClient(0));
auto* client = bridge._slots[0].client;
assert(bridge.reconnectSlotClient(0) == ESP_OK);
client->errorWithoutDisconnect();
assert(bridge._slot_transport_fault_pending[0]);
}
''', fixture=old_callback, expect_rejection=True)
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -21,6 +21,7 @@ PREAMBLE = r'''
#include <cstddef>
#include "helpers/MQTTConnectionAdmission.h"
#include "helpers/MQTTConnectionPolicy.h"
#include "helpers/MQTTTransportHealth.h"
struct esp_mqtt_client_config_t {
#if ESP_IDF_VERSION_MAJOR == 5
struct { int keepalive = 0, message_retransmit_timeout = 0; } session;
@@ -98,6 +99,7 @@ int main() {
RECONFIGURE_PREAMBLE = r'''
#include "helpers/MQTTConnectionAdmission.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;
@@ -147,8 +149,11 @@ struct MQTTBridge {
unsigned long last_reconnect_attempt = 0, last_log_time = 0, last_deferred_log_ms = 0;
uint8_t reconnect_backoff = 0, max_backoff_failures = 0;
bool circuit_breaker_tripped = false;
MQTTTransportHealth::State transport_health;
} _slots[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _stop_requested{false}, _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_reset_pending[RUNTIME_MQTT_SLOTS] = {};
bool _slot_force_jwt_mint[RUNTIME_MQTT_SLOTS] = {};
bool _slot_reconfigure_pending[RUNTIME_MQTT_SLOTS] = {};
bool _initialized = true;
@@ -161,6 +166,7 @@ struct MQTTBridge {
bool canStartSlotConnection(int) const;
void optimizeMqttClientConfig(PsychicMqttClient*, bool);
void teardownSlot(int, bool force = false);
void updateSlotTransportDiag(int, uint32_t) {}
void applySlotPreset(int, const char*);
bool setupSlot(int index) {
auto& slot = _slots[index];
+52
View File
@@ -79,6 +79,7 @@ PREAMBLE = r'''
#undef private
#include "helpers/MQTTReplyFormat.h"
#include "helpers/MQTTConnectionHealth.h"
#include "helpers/MQTTTransportHealth.h"
#include "helpers/MQTTPacketFilter.h"
#include "helpers/bridges/MQTTErrorLabels.h"
template<typename... Args> void test_log(Args&&...) {}
@@ -161,6 +162,14 @@ struct MQTTBridge {
int last_sock_errno = 0;
};
MQTTSlot _slots[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_recovering[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint32_t> _slot_health_recoveries[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint8_t> _slot_health_reason[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint32_t> _slot_health_ack_wait_secs[RUNTIME_MQTT_SLOTS] = {};
MQTTBridge() {
for (auto& wait : _slot_health_ack_wait_secs) wait.store(UINT32_MAX);
}
bool _initialized = true;
bool ready = true;
MQTTPrefs* _obs = nullptr;
@@ -437,6 +446,49 @@ int main() {
}
''')
def test_production_recovery_diagnostics_never_claim_no_errors(self):
self.compile_run(r'''
int main() {
PsychicMqttClient client;
MQTTBridge bridge;
bridge._slots[0].client = &client;
s_mqtt_bridge_instance = &bridge;
bridge._slot_health_reason[0] = static_cast<uint8_t>(
MQTTTransportHealth::RecoveryReason::AckTimeout);
bridge._slot_health_recoveries[0] = 3;
bridge._slot_health_ack_wait_secs[0] = 120;
for (bool fault_latched : {false, true}) {
bridge._slot_health_recovering[0] = !fault_latched;
bridge._slot_transport_fault_pending[0] = fault_latched;
char reply[160];
MQTTBridge::formatSlotDiagReply(reply, sizeof(reply), 0);
assert(strcmp(reply, "> mqtt1: recover, pub_ack:0, rec:3/puback, ack_wait:120s") == 0);
assert(strstr(reply, "no errors") == nullptr);
}
bridge._slot_health_recovering[0] = false;
bridge._slot_transport_fault_pending[0] = false;
char recovered[160];
MQTTBridge::formatSlotDiagReply(recovered, sizeof(recovered), 0);
assert(strstr(recovered, "> mqtt1: ok, no errors") == recovered);
assert(strstr(recovered, "rec:3/puback") != nullptr); // retain history
bridge._slot_health_recovering[0] = true;
bridge._slot_health_recoveries[0] = UINT32_MAX;
bridge._slot_health_ack_wait_secs[0] = UINT32_MAX - 1;
for (size_t capacity = 1; capacity <= 160; ++capacity) {
unsigned char bounded[168];
memset(bounded, 0xA5, sizeof(bounded));
char* reply = reinterpret_cast<char*>(bounded);
MQTTBridge::formatSlotDiagReply(reply, capacity, 0);
assert(memchr(reply, '\0', capacity) != nullptr);
for (size_t pos = capacity; pos < sizeof(bounded); ++pos) assert(bounded[pos] == 0xA5);
// Every appended numeric field is omitted whole when it cannot fit.
if (strstr(reply, "rec:")) assert(strstr(reply, "rec:4294967295/puback"));
if (strstr(reply, "ack_wait:")) assert(strstr(reply, "ack_wait:4294967294s"));
}
}
''')
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Exercise bounded MQTT liveness clocks without an SDK or PlatformIO build."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class MqttTransportHealthTest(unittest.TestCase):
def compile_run(self, main):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
source = ('#include <cassert>\n#include <cstring>\n#include <initializer_list>\n'
'#include "helpers/MQTTTransportHealth.h"\n'
'using namespace MQTTTransportHealth;\n') + main
with tempfile.TemporaryDirectory(prefix="meshcore-mqtt-health-") as directory:
work = Path(directory)
path, binary = work / "health.cpp", work / "health"
path.write_text(source, encoding="ascii")
result = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I", str(ROOT / "src"), str(path), "-o", str(binary)],
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)
def test_repeated_acceptance_cannot_postpone_oldest_missing_ack(self):
self.compile_run(r'''
int main() {
State state;
state.acceptedQos1(1000, 7);
for (uint32_t now = 11000; now < 121000; now += 10000) {
state.acceptedQos1(now, 7);
assert(state.ack_started_ms == 1000 && state.ack_snapshot == 7);
assert(!state.ackExpired(now));
}
assert(state.ackExpired(121000));
}
''')
def test_ack_advance_clears_watch_and_new_enqueue_starts_a_new_deadline(self):
self.compile_run(r'''
int main() {
State state;
state.acceptedQos1(1000, 7);
state.observeAck(7);
assert(state.ack_pending);
state.observeAck(8);
assert(!state.ack_pending && !state.ackExpired(121000));
state.acceptedQos1(300000, 8);
assert(state.ack_started_ms == 300000 && state.ack_snapshot == 8);
assert(!state.ackExpired(419999) && state.ackExpired(420000));
// An ACK arriving between maintenance passes is recognized on enqueue.
state.acceptedQos1(450000, 9);
assert(state.ack_started_ms == 450000 && state.ack_snapshot == 9);
}
''')
def test_immediate_ack_race_uses_snapshot_from_before_enqueue(self):
self.compile_run(r'''
int main() {
State state;
const uint32_t before_enqueue = 11;
const uint32_t after_enqueue = 12; // broker event arrived inside SDK enqueue
state.acceptedQos1(1000, before_enqueue);
state.observeAck(after_enqueue);
assert(!state.ack_pending && !state.ackExpired(121000));
}
''')
def test_zero_start_ticks_and_millis_wrap_preserve_bounded_deadlines(self):
self.compile_run(r'''
int main() {
for (uint32_t start : {uint32_t(0), UINT32_MAX - 60000}) {
State state;
state.beginAttempt(start);
state.acceptedQos1(start, UINT32_MAX);
assert(!state.attemptExpired(start + kAttemptTimeoutMs - 1));
assert(state.attemptExpired(start + kAttemptTimeoutMs));
assert(!state.ackExpired(start + kAckTimeoutMs - 1));
assert(state.ackExpired(start + kAckTimeoutMs));
// The acknowledgment counter may wrap too; inequality still proves progress.
state.observeAck(0);
assert(!state.ack_pending);
state.completeAttempt();
assert(!state.attemptExpired(start + kAttemptTimeoutMs));
}
}
''')
def test_recovery_keeps_first_reason_and_saturates_lifetime_counter(self):
self.compile_run(r'''
int main() {
State state;
state.beginAttempt(1000);
state.acceptedQos1(1000, 3);
state.requestRecovery(RecoveryReason::TransportError);
state.requestRecovery(RecoveryReason::AckTimeout);
assert(state.recovery_reason == RecoveryReason::TransportError);
state.recovered();
assert(state.recoveries == 1 && !state.attempt_active && !state.ack_pending);
assert(state.recovery_reason == RecoveryReason::None);
assert(state.last_recovery_reason == RecoveryReason::TransportError);
state.recoveries = UINT32_MAX;
state.requestRecovery(RecoveryReason::PublishFailure);
state.recovered();
assert(state.recoveries == UINT32_MAX);
assert(state.last_recovery_reason == RecoveryReason::PublishFailure);
assert(strcmp(reasonLabel(RecoveryReason::HandshakeTimeout), "handshake") == 0);
assert(strcmp(reasonLabel(RecoveryReason::AckTimeout), "puback") == 0);
}
''')
def test_quiet_session_without_accepted_qos1_never_times_out(self):
self.compile_run(r'''
int main() {
State state;
state.beginAttempt(1000);
state.completeAttempt();
for (uint32_t now = 0; now <= 7 * 24 * 60 * 60 * 1000; now += 60000) {
state.observeAck(0);
assert(!state.attemptExpired(now) && !state.ackExpired(now));
assert(state.recovery_reason == RecoveryReason::None);
}
}
''')
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -32,6 +32,7 @@ PREAMBLE = r'''
#include <cassert>
#include <functional>
#include "helpers/MQTTConnectionHealth.h"
#include "helpers/MQTTTransportHealth.h"
using esp_err_t = int;
using esp_mqtt_client_handle_t = void*;
constexpr int ESP_OK=0, ESP_FAIL=-1, ESP_ERR_INVALID_STATE=2, ESP_ERR_NO_MEM=3;
@@ -53,6 +54,7 @@ static int register_result=ESP_OK, config_result=ESP_OK;
static int start_result=ESP_OK, reconnect_result=ESP_OK;
static int init_calls=0, register_calls=0, destroy_calls=0, config_calls=0;
static int start_calls=0, reconnect_calls=0;
unsigned long millis() { return 1000; }
static std::function<void()> sdk_callback;
void* test_malloc(size_t size) { return fail_malloc ? nullptr : std::malloc(size); }
#define malloc test_malloc
@@ -88,9 +90,11 @@ struct MQTTBridge {
struct MQTTSlot {
PsychicMqttClient* client=nullptr;
uint32_t start_failures=0;
MQTTTransportHealth::State transport_health;
};
MQTTSlot _slots[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _stop_requested{false};
bool hasPendingSlotConnection() const;
bool canStartSlotConnection(int index) const;
+60 -2
View File
@@ -22,6 +22,8 @@ BRIDGE = "src/helpers/bridges/MQTTBridge.cpp"
PREAMBLE = r'''
#include <helpers/WiFiNetworkPowerPolicy.h>
#include <helpers/MQTTConnectionPolicy.h>
#include <helpers/MQTTConnectionHealth.h>
#include <helpers/MQTTTransportHealth.h>
#include <helpers/esp32/SntpOperationCoordinator.h>
#include <atomic>
#include <cassert>
@@ -139,17 +141,18 @@ void configTime(int a, int b, const char* primary) {
}
struct Client {
bool started = true, fail_stop = false;
bool started = true, fail_stop = false, live = false;
int stops = 0;
uint32_t publish_acks = 0;
uint32_t getPublishAck() const { return publish_acks; }
bool isStarted() const { return started; }
bool connected() const { return live; }
void disconnect() {
require(!mesh::wifi::networkPower().backgroundSuspended(),
"TLS stopped after handing station off");
++stops;
events.push_back("tls-stop");
if (!fail_stop) started = false;
if (!fail_stop) started = live = false;
if (on_client_stop) on_client_stop();
}
};
@@ -185,9 +188,18 @@ struct MQTTBridge {
uint32_t connected_at_ms = 0, last_reconnect_attempt = 200U;
uint8_t reconnect_backoff = 5;
Client* client = nullptr;
uint32_t connect_failures = 0, current_outage_started_ms = 0;
MQTTTransportHealth::State transport_health;
} _slots[RUNTIME_MQTT_SLOTS];
using MQTTSlot = Slot;
Client clients[RUNTIME_MQTT_SLOTS];
std::atomic<bool> _slot_attempt_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_transport_fault_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_reset_pending[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _slot_health_recovering[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint32_t> _slot_health_recoveries[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint8_t> _slot_health_reason[RUNTIME_MQTT_SLOTS] = {};
std::atomic<uint32_t> _slot_health_ack_wait_secs[RUNTIME_MQTT_SLOTS] = {};
std::atomic<bool> _status_publish_pending[RUNTIME_MQTT_SLOTS] = {};
uint32_t _network_publish_acks[RUNTIME_MQTT_SLOTS] = {};
bool _network_espnow_restore_pending = false;
@@ -237,6 +249,10 @@ struct MQTTBridge {
bool isRunning() const { return true; }
bool isNetworkSuspended() const { return _network_suspended.load(); }
bool requestNtpTimeEstimate();
bool hasPendingSlotConnection() const;
void serviceSlotTransportHealth(uint32_t now);
bool recoverSlotTransport(int index, uint32_t now);
void updateSlotTransportDiag(int index, uint32_t now);
bool serviceNetworkPower(uint32_t now);
void serviceEspNowChannelRestore(uint32_t now);
void serviceBackgroundNtp(uint32_t now);
@@ -336,6 +352,40 @@ static void failedSdkStopWithholdsHandoff() {
"successful retry leaked a transport or radio");
}
static void failedSuspendLateConnectCannotAbandonTransportOwnership() {
resetEnvironment();
MQTTBridge bridge;
for (auto& pending : bridge._slot_attempt_pending) pending = false;
bridge.serviceNetworkPower(clock_ms);
bridge.clients[1].fail_stop = true;
clock_ms += grace;
require(!bridge.serviceNetworkPower(clock_ms), "failed stop did not hold handoff");
require(bridge._slot_transport_fault_pending[1] && bridge.hasPendingSlotConnection(),
"failed suspension abandoned a running MQTT transport");
// A foreground owner wakes networking and a late CONNACK sets a fresh-session
// latch. Neither proves the failed SDK stop completed or frees admission.
mesh::wifi::networkPower().setHold(mesh::wifi::NetworkPowerHold::WebConfig, true);
bridge._slots[1].connected = bridge.clients[1].live = true;
bridge._slot_attempt_pending[1] = false;
bridge._slot_health_reset_pending[1] = true;
require(bridge.serviceNetworkPower(++clock_ms), "foreground owner did not keep radio");
bridge.serviceSlotTransportHealth(clock_ms);
require(bridge.clients[1].started && bridge.hasPendingSlotConnection(),
"foreground wake or late CONNACK released failed-stop reservation");
require(!bridge._slots[1].connected && !bridge._network_suspended,
"failed transport was advertised healthy or handed station away");
require(bridge._slots[1].transport_health.recoveries == 0,
"failed SDK stop was counted as recovered");
bridge.clients[1].fail_stop = false;
bridge.serviceSlotTransportHealth(++clock_ms);
require(!bridge.clients[1].started && !bridge.hasPendingSlotConnection(),
"proven successful stop did not release ownership");
require(bridge._slots[1].transport_health.recoveries == 1,
"successful owner cleanup was not counted");
require(WiFi.offs == 0 && WiFi.disconnects == 0,
"transport recovery stopped foreground WiFi");
}
static void recoveryHasOneBoundedWindow() {
for (uint32_t start : {100U, UINT32_MAX - 10000U}) {
resetEnvironment();
@@ -686,6 +736,7 @@ int main(int argc, char** argv) {
try {
if (name == "handoff") failureStopsBothServicesBeforeWifi();
else if (name == "failed-stop") failedSdkStopWithholdsHandoff();
else if (name == "failed-stop-wake") failedSuspendLateConnectCannotAbandonTransportOwnership();
else if (name == "recovery") recoveryHasOneBoundedWindow();
else if (name == "owners") foregroundAndEspNowArePreserved();
else if (name == "background-estimate") automaticClockEstimateCannotWakeSuspendedNetwork();
@@ -769,6 +820,10 @@ class MqttWiFiNetworkPowerTests(unittest.TestCase):
"void MQTTBridge::refreshNTP(",
"void MQTTBridge::pollNtpRefresh(",
"bool MQTTBridge::requestNtpTimeEstimate(",
"bool MQTTBridge::hasPendingSlotConnection(",
"void MQTTBridge::serviceSlotTransportHealth(",
"bool MQTTBridge::recoverSlotTransport(",
"void MQTTBridge::updateSlotTransportDiag(",
))
cls.temporary = tempfile.TemporaryDirectory(prefix="meshcore-mqtt-network-power-")
cls.addClassCleanup(cls.temporary.cleanup)
@@ -815,6 +870,9 @@ class MqttWiFiNetworkPowerTests(unittest.TestCase):
def test_failed_sdk_transport_stop_keeps_station_owned_and_awake(self):
self.run_case("failed-stop")
def test_failed_suspend_late_connect_and_foreground_wake_keep_sdk_reservation(self):
self.run_case("failed-stop-wake")
def test_recovery_restarts_once_and_stops_after_bounded_probe_across_wrap(self):
self.run_case("recovery")