Files
MeshCore/test/test_mqtt_handshake_scheduler.py
T

948 lines
39 KiB
Python

#!/usr/bin/env python3
"""Exercise production MQTT scheduling with delayed callbacks and bounded heaps.
The actual maintenance/admission/reconnect methods run with a deterministic
clock. Hardware, SDK task starts, and token generation are controllable seams.
"""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_mqtt_transport_results import method
ROOT = Path(__file__).resolve().parents[1]
PREAMBLE = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#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)
using esp_err_t = int;
constexpr int ESP_OK = 0, ESP_ERR_INVALID_ARG = 1, ESP_ERR_INVALID_STATE = 2;
constexpr int ESP_ERR_NO_MEM = 3, RUNTIME_MQTT_SLOTS = 6, WL_CONNECTED = 1;
constexpr int WL_DISCONNECTED = 0, WIFI_POWER_11dBm = 11;
using wl_status_t = int;
using wifi_ps_type_t = int;
constexpr int WIFI_PS_NONE = 0, WIFI_PS_MAX_MODEM = 1, WIFI_PS_MIN_MODEM = 2;
namespace mesh { namespace wifi {
constexpr bool kPrimaryEspNowRadio = false;
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;
static size_t dma_free = 100000, dma_largest = 20000;
static unsigned long s_wifi_connected_at = 0;
uint32_t millis() { return clock_ms; }
unsigned long fake_time(void*) { return wall_seconds; }
#define time fake_time
struct {
int state = WL_CONNECTED;
int status() const { return state; }
void setTxPower(int) {}
void disconnect() { state = WL_DISCONNECTED; }
} 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 || 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;
}
const char* esp_err_to_name(int) { return "mock"; }
struct Preset {
const char* server_url = "wss://broker/mqtt";
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) {
assert(initialized && !started && dma_free >= 6536);
dma_free -= 6536;
}
initialized = started = true;
return ESP_OK;
}
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;
// Releasing the old internal TLS session creates room for its replacement.
dma_free += 44000; dma_largest = 20000;
}
void setCredentials(const char*, const char*) { ++credentials; }
};
struct MQTTBridge {
struct MQTTSlot {
bool enabled = true, connected = false, initial_connect_done = false;
const Preset* preset = nullptr;
PsychicMqttClient* client = nullptr;
char host[64] = "broker", broker_uri[128] = {}, audience[64] = {};
uint16_t port = 8883;
char* auth_token = nullptr;
unsigned long token_expires_at = 0, last_token_renewal = 0;
unsigned long last_reconnect_attempt = 0, connected_at_ms = 0;
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;
bool _slots_setup_done = true;
bool _wifi_status_initialized = true, _manage_wifi = false;
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;
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{};
unsigned long _last_slot_reconnect_ms = 0;
int _max_active_slots = 5, mints = 0;
AlertFaultPolicy::OutageSnapshot wifiOutage() const { return _wifi_outage; }
void setWifiOutage(AlertFaultPolicy::OutageSnapshot snapshot) { _wifi_outage = snapshot; }
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) {
setups.push_back(index);
_slots[index].client = &clients[index];
if (!setup_succeeds[index]) return false;
if (reconnectSlotClient(index) != ESP_OK) return false;
_slots[index].initial_connect_done = true;
return true;
}
bool createSlotAuthToken(int index) {
++mints;
strcpy(tokens[index], "fresh");
_slots[index].auth_token = tokens[index];
_slots[index].token_expires_at = wall_seconds + 3300;
return true;
}
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;
}
int activatedSlotCount() const;
bool canActivateSlot(int index) const;
bool hasPendingSlotConnection() const;
bool canStartSlotConnection(int index) const;
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&);
};
'''
class MqttHandshakeSchedulerTest(unittest.TestCase):
def fixture(self):
bridge = "src/helpers/bridges/MQTTBridge.cpp"
return PREAMBLE + "\n".join(method(bridge, signature) for signature in (
"int MQTTBridge::activatedSlotCount() const",
"bool MQTTBridge::canActivateSlot(int index) const",
"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, 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() 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)
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'''
int main() {
MQTTBridge bridge;
clock_ms = 1000;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>{0});
assert(bridge._slot_attempt_pending[0]);
// The time guard has expired, but the earlier asynchronous handshake is
// still pending. Ordinary maintenance must not launch another SDK request.
clock_ms = 16000;
bridge.maintainSlotConnections();
clock_ms = 60000;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>{0});
assert(bridge.clients[0].starts == 1 && bridge.clients[0].reconnects == 0);
bridge.complete(0);
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>({0, 1}));
assert(bridge._slot_attempt_pending[1]);
bridge.complete(1);
clock_ms += 14999;
bridge.maintainSlotConnections();
assert(bridge.setups.size() == 2);
++clock_ms;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>({0, 1, 2}));
bridge._stop_requested = true;
bridge.complete(2);
clock_ms += 60000;
bridge.maintainSlotConnections();
assert(bridge.setups.size() == 3);
}
''')
def test_setup_failure_waits_full_minute_even_when_client_was_allocated(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); bridge.setup_succeeds[0] = false;
clock_ms = 1000;
bridge.maintainSlotConnections();
assert(bridge.setups.size() == 1 && bridge._slots[0].client);
assert(!bridge._slots[0].initial_connect_done && bridge._slots[0].enabled);
assert(!bridge.hasPendingSlotConnection());
clock_ms = 1050;
bridge.maintainSlotConnections();
clock_ms = 60999;
bridge.maintainSlotConnections();
assert(bridge.setups.size() == 1);
clock_ms = 61000; bridge.setup_succeeds[0] = true;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>({0, 0}));
assert(bridge._slots[0].initial_connect_done && bridge.hasPendingSlotConnection());
}
''')
def test_failed_slot_does_not_starve_healthy_slot_or_consume_active_cap(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge._max_active_slots = 1; bridge.setup_succeeds[0] = false;
clock_ms = 1000;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>{0});
assert(bridge.activatedSlotCount() == 0);
clock_ms = 1050;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>({0, 1}));
assert(bridge.activatedSlotCount() == 1);
for (int i = 2; i < RUNTIME_MQTT_SLOTS; ++i) assert(!bridge._slots[i].enabled);
bridge.complete(1); clock_ms += 60000;
bridge.maintainSlotConnections();
assert(!bridge._slots[0].enabled && bridge.setups.size() == 2);
}
''')
def test_low_dma_memory_defers_setup_without_consuming_retry_or_capacity(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); clock_ms = 1000; dma_free = 32767;
bridge.maintainSlotConnections();
assert(bridge.setups.empty() && bridge._slots[0].last_reconnect_attempt == 0);
assert(bridge._slots[0].enabled && bridge.activatedSlotCount() == 0);
dma_free = 32768; dma_largest = 8191;
bridge.maintainSlotConnections();
assert(bridge.setups.empty() && bridge._slots[0].last_reconnect_attempt == 0);
assert(!bridge.hasPendingSlotConnection() && bridge.activatedSlotCount() == 0);
dma_largest = 8192;
bridge.maintainSlotConnections();
assert(bridge.setups == std::vector<int>{0} && bridge.activatedSlotCount() == 1);
assert(bridge.clients[0].starts == 1 && bridge.clients[0].reconnects == 0);
}
''')
def test_stopped_client_defers_while_retained_task_reconnects_at_warm_budget(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
for (int i = 2; i < RUNTIME_MQTT_SLOTS; ++i) bridge._slots[i].enabled = false;
clock_ms = 70000;
dma_free = 16384; dma_largest = 4096;
auto& stopped = bridge._slots[0];
auto& retained = bridge._slots[1];
stopped.client = &bridge.clients[0];
retained.client = &bridge.clients[1];
stopped.initial_connect_done = retained.initial_connect_done = true;
stopped.client->initialized = retained.client->initialized = true;
retained.client->started = true;
// An existing handle with a stopped task needs the task-restart allowance.
// Its deferral must leave this cycle available for the retained SDK task.
assert(!bridge.canStartSlotConnection(0));
assert(bridge.canStartSlotConnection(1));
bridge.maintainSlotConnections();
assert(stopped.client->starts == 0 && stopped.client->reconnects == 0);
assert(stopped.last_reconnect_attempt == 0 && stopped.reconnect_backoff == 0);
assert(stopped.start_failures == 0 && !bridge._slot_attempt_pending[0]);
assert(retained.client->starts == 0 && retained.client->reconnects == 1);
assert(bridge._slot_attempt_pending[1]);
// Completion and newly available task-restart headroom allow an SDK start,
// with no extra delay introduced by the earlier memory-only deferral.
bridge.complete(1);
clock_ms += 15000;
dma_free = 24576; dma_largest = 8192;
bridge.maintainSlotConnections();
assert(stopped.client->starts == 1 && stopped.client->reconnects == 0);
assert(stopped.client->isStarted() && bridge._slot_attempt_pending[0]);
assert(stopped.last_reconnect_attempt == clock_ms);
assert(retained.client->reconnects == 1 && bridge.setups.empty());
}
''')
def test_internal_tls_renewal_releases_old_session_before_new_memory_admission(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
bridge.only(0); clock_ms = 70000; ESP.size = 0;
const Preset jwt{"wss://broker/mqtt", MQTT_AUTH_JWT, 3300, true};
auto& slot = bridge._slots[0];
slot.preset = &jwt; slot.client = &bridge.clients[0];
slot.initial_connect_done = slot.connected = true;
slot.client->initialized = slot.client->started = slot.client->live = true;
slot.token_expires_at = wall_seconds + 120;
// Before releasing the current TLS session, the full admission check fails.
dma_free = 20000; dma_largest = 10000;
assert(!bridge.canStartSlotConnection(0));
bridge.maintainSlotConnections();
assert(bridge.mints == 1 && slot.client->bounces == 1);
assert(slot.client->reconnects == 1 && bridge._slot_attempt_pending[0]);
assert(slot.client->credentials == 1 && dma_free == 64000);
// Another pending slot still prevents renewal from tearing down a live link.
bridge.complete(0); bridge._slot_attempt_pending[1] = true;
slot.token_expires_at = wall_seconds + 120; clock_ms += 60000;
bridge.maintainSlotConnections();
assert(bridge.mints == 1 && slot.client->bounces == 1);
}
''')
def test_wifi_loss_restarts_three_retained_clients_without_cold_buffer_double_charge(self):
self.compile_run(r'''
int main() {
MQTTBridge bridge;
clock_ms = 70000;
// The live V4's three-broker measurement left about 22.7 KiB DMA heap.
// The SDK seam uses the pinned 6144-byte stack and 344-byte TCB, plus a
// simulated 48-byte allocator allowance. Transport/config buffers remain
// owned by initialized handles after stop; SDK heap calls are not executed.
dma_free = 22768; dma_largest = 22516;
for (int i = 0; i < 3; ++i) {
auto& slot = bridge._slots[i];
slot.client = &bridge.clients[i];
slot.initial_connect_done = slot.connected = true;
slot.client->initialized = slot.client->started = slot.client->live = true;
slot.client->model_task_heap = true;
slot.client->disconnected = [&bridge, i] { bridge._slots[i].connected = false; };
}
bridge._slots[5].enabled = false;
WiFi.state = WL_DISCONNECTED;
assert(!bridge.handleWiFiConnection(clock_ms));
assert(bridge.wifiOutage().down && dma_free == 42376);
for (int i = 0; i < 3; ++i) {
assert(bridge.clients[i].isInitialized() && !bridge.clients[i].isStarted());
assert(bridge.clients[i].stops == 1 && !bridge._slots[i].connected);
assert(!bridge._slot_attempt_pending[i]);
}
bridge.maintainSlotConnections();
for (int i = 0; i < 3; ++i) assert(bridge.clients[i].starts == 0);
clock_ms += 11000;
WiFi.state = WL_CONNECTED;
assert(bridge.handleWiFiConnection(clock_ms));
assert(!bridge.wifiOutage().down);
// Restart admission still fails safely if either aggregate or contiguous
// headroom is inadequate. Neither failure consumes retry/backoff state.
dma_free = 24575;
bridge.maintainSlotConnections();
dma_free = 24576; dma_largest = 8191;
bridge.maintainSlotConnections();
for (int i = 0; i < 3; ++i) {
assert(bridge.clients[i].starts == 0 && bridge._slots[i].last_reconnect_attempt == 0);
assert(bridge._slots[i].reconnect_backoff == 0 && bridge._slots[i].start_failures == 0);
}
dma_free = 42376; dma_largest = 22516;
bridge.maintainSlotConnections();
assert(bridge.clients[0].starts == 1 && dma_free == 35840);
assert(bridge.hasPendingSlotConnection());
clock_ms += 60000;
bridge.maintainSlotConnections();
assert(bridge.clients[1].starts == 0 && bridge.clients[2].starts == 0);
bridge.complete(0);
bridge.maintainSlotConnections();
assert(bridge.clients[1].starts == 1 && dma_free == 29304);
clock_ms += 15000;
bridge.maintainSlotConnections();
assert(bridge.clients[2].starts == 0); // Callback, not time, ends a pending attempt.
bridge.complete(1);
assert(!MQTTConnectionAdmission::canStart(
true, true, MQTTConnectionAdmission::ClientMemoryState::Uninitialized,
dma_free, dma_largest));
assert(bridge.canStartSlotConnection(2));
bridge.maintainSlotConnections();
assert(bridge.clients[2].starts == 1 && dma_free == 22768);
bridge.complete(2);
clock_ms += 15000;
bridge.maintainSlotConnections();
for (int i = 0; i < 3; ++i) {
assert(bridge._slots[i].connected && bridge.clients[i].isStarted());
assert(bridge.clients[i].starts == 1 && bridge.clients[i].reconnects == 0);
assert(bridge._slots[i].start_failures == 0);
}
// Additional clients still need the full first-start allowance and wait.
assert(bridge.setups.empty());
assert(!bridge.canStartSlotConnection(3) && !bridge.canStartSlotConnection(4));
assert(!bridge.hasPendingSlotConnection());
}
''')
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()