@alteriom/painlessmesh 1.9.20 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ //************************************************************
2
+ // Tuning the TCP connection retry behaviour (issue #378)
3
+ //
4
+ // painlessMesh retries a failed TCP connection with exponential backoff
5
+ // before giving up and falling back to a full WiFi reconnect. The defaults
6
+ // (5 retries, 1s base delay -> 1s, 2s, 4s, 8s, 8s) are tuned for general
7
+ // purpose meshes and deliberately favour reliability over speed.
8
+ //
9
+ // setTcpRetryConfig() lets you pick a different trade-off. Three ready-made
10
+ // profiles are shown below; switch between them with ACTIVE_PROFILE.
11
+ //
12
+ // Read examples/tcpRetryConfig/README.md before changing these values - the
13
+ // defaults exist because 1.9.x raised them to fix real mesh instability.
14
+ //************************************************************
15
+ #include <painlessMesh.h>
16
+
17
+ #define MESH_SSID "whateverYouLike"
18
+ #define MESH_PASSWORD "somethingSneaky"
19
+ #define MESH_PORT 5555
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Profiles
23
+ // ---------------------------------------------------------------------------
24
+ #define PROFILE_REALTIME 1 // low latency: fail fast, reconnect fast
25
+ #define PROFILE_RELIABLE 2 // industrial: many retries, long backoff
26
+ #define PROFILE_BATTERY 3 // conserve power: few retries, long block
27
+
28
+ // >>> Change this line to try a different profile <<<
29
+ #define ACTIVE_PROFILE PROFILE_REALTIME
30
+
31
+ // Prototypes
32
+ void sendMessage();
33
+ void receivedCallback(uint32_t from, String &msg);
34
+ void newConnectionCallback(uint32_t nodeId);
35
+ void droppedConnectionCallback(uint32_t nodeId);
36
+
37
+ Scheduler userScheduler;
38
+ painlessMesh mesh;
39
+
40
+ Task taskSendMessage(TASK_SECOND * 5, TASK_FOREVER, &sendMessage);
41
+
42
+ // Build the retry configuration for the selected profile.
43
+ painlessmesh::tcp::TcpRetryConfig buildRetryConfig() {
44
+ painlessmesh::tcp::TcpRetryConfig cfg; // starts at the library defaults
45
+
46
+ #if ACTIVE_PROFILE == PROFILE_REALTIME
47
+ // Real-time sensor / LED meshes: a stalled node is worse than a dropped
48
+ // one. Give up on the TCP handshake almost immediately and go straight
49
+ // back to scanning for another parent.
50
+ cfg.maxRetries = 1; // default 5
51
+ cfg.retryDelayMs = 200; // default 1000
52
+ cfg.stabilizationDelayMs = 100; // default 500
53
+ cfg.exhaustionReconnectDelayMs = 1000; // default 10000
54
+ cfg.failureBlockDurationMs = 5000; // default 60000
55
+
56
+ #elif ACTIVE_PROFILE == PROFILE_RELIABLE
57
+ // Industrial / high-reliability meshes: connectivity matters more than
58
+ // how long it takes to get there. Retry patiently and keep a failed peer
59
+ // out of the running for a good while.
60
+ cfg.maxRetries = 10; // default 5 (this is the maximum)
61
+ cfg.retryDelayMs = 2000; // default 1000
62
+ cfg.stabilizationDelayMs = 1000; // default 500
63
+ cfg.exhaustionReconnectDelayMs = 30000; // default 10000
64
+ // Must exceed one full failure cycle (126s of retries + 30s reconnect),
65
+ // otherwise a dead peer leaves the blocklist before we finished failing
66
+ // over and gets re-selected immediately.
67
+ cfg.failureBlockDurationMs = 180000; // default 60000
68
+
69
+ #elif ACTIVE_PROFILE == PROFILE_BATTERY
70
+ // Battery-powered nodes: every retry is radio time. Few attempts, spaced
71
+ // widely, and a long blocklist so we do not keep waking up for a peer
72
+ // that is known to be down.
73
+ cfg.maxRetries = 2; // default 5
74
+ cfg.retryDelayMs = 3000; // default 1000
75
+ cfg.stabilizationDelayMs = 500; // default 500
76
+ cfg.exhaustionReconnectDelayMs = 60000; // default 10000
77
+ cfg.failureBlockDurationMs = 300000; // default 60000
78
+
79
+ #else
80
+ #error "ACTIVE_PROFILE must be one of PROFILE_REALTIME, PROFILE_RELIABLE, PROFILE_BATTERY"
81
+ #endif
82
+
83
+ return cfg;
84
+ }
85
+
86
+ void setup() {
87
+ Serial.begin(115200);
88
+
89
+ mesh.setDebugMsgTypes(ERROR | STARTUP | CONNECTION);
90
+
91
+ // Apply the retry configuration BEFORE init() so the very first connection
92
+ // attempt already uses it.
93
+ mesh.setTcpRetryConfig(buildRetryConfig());
94
+
95
+ mesh.init(MESH_SSID, MESH_PASSWORD, &userScheduler, MESH_PORT);
96
+
97
+ // Read the configuration back. Values outside safe operating bounds are
98
+ // clamped by the setter, so this prints what is actually in effect - not
99
+ // necessarily what was requested.
100
+ painlessmesh::tcp::TcpRetryConfig active = mesh.getTcpRetryConfig();
101
+ Serial.println();
102
+ Serial.println(F("Effective TCP retry configuration:"));
103
+ Serial.printf(" maxRetries = %u\n",
104
+ (unsigned)active.maxRetries);
105
+ Serial.printf(" retryDelayMs = %u\n",
106
+ (unsigned)active.retryDelayMs);
107
+ Serial.printf(" stabilizationDelayMs = %u\n",
108
+ (unsigned)active.stabilizationDelayMs);
109
+ Serial.printf(" exhaustionReconnectDelayMs = %u\n",
110
+ (unsigned)active.exhaustionReconnectDelayMs);
111
+ Serial.printf(" failureBlockDurationMs = %u\n",
112
+ (unsigned)active.failureBlockDurationMs);
113
+
114
+ // Worst-case time spent retrying before falling back to a WiFi reconnect.
115
+ uint32_t worstCase = 0;
116
+ for (uint8_t i = 0; i < active.maxRetries; ++i) {
117
+ worstCase += painlessmesh::tcp::retryBackoffDelay(active, i);
118
+ }
119
+ Serial.printf(" -> worst-case retry time = %u ms\n", (unsigned)worstCase);
120
+ Serial.printf(" -> plus reconnect delay = %u ms\n",
121
+ (unsigned)(worstCase + active.exhaustionReconnectDelayMs));
122
+ Serial.println();
123
+
124
+ mesh.onReceive(&receivedCallback);
125
+ mesh.onNewConnection(&newConnectionCallback);
126
+ mesh.onDroppedConnection(&droppedConnectionCallback);
127
+
128
+ userScheduler.addTask(taskSendMessage);
129
+ taskSendMessage.enable();
130
+ }
131
+
132
+ void loop() {
133
+ mesh.update();
134
+ }
135
+
136
+ void sendMessage() {
137
+ String msg = "Hello from node ";
138
+ msg += mesh.getNodeId();
139
+ mesh.sendBroadcast(msg);
140
+ }
141
+
142
+ void receivedCallback(uint32_t from, String &msg) {
143
+ Serial.printf("tcpRetryConfig: Received from %u msg=%s\n", from, msg.c_str());
144
+ }
145
+
146
+ void newConnectionCallback(uint32_t nodeId) {
147
+ Serial.printf("--> Connected to node %u\n", nodeId);
148
+ }
149
+
150
+ void droppedConnectionCallback(uint32_t nodeId) {
151
+ // With an aggressive profile you should expect to see this more often -
152
+ // and to see the reconnect that follows it happen much sooner.
153
+ Serial.printf("--> Dropped connection to node %u\n", nodeId);
154
+ }
package/keywords.txt CHANGED
@@ -4,6 +4,7 @@
4
4
  painlessMesh KEYWORD1
5
5
  Scheduler KEYWORD1
6
6
  Task KEYWORD1
7
+ TcpRetryConfig KEYWORD1
7
8
 
8
9
  # Methods and Functions (KEYWORD2)
9
10
  init KEYWORD2
@@ -18,6 +19,8 @@ getNodeList KEYWORD2
18
19
  getNodeId KEYWORD2
19
20
  getNodeTime KEYWORD2
20
21
  setDebugMsgTypes KEYWORD2
22
+ setTcpRetryConfig KEYWORD2
23
+ getTcpRetryConfig KEYWORD2
21
24
  subConnectionJson KEYWORD2
22
25
  asNodeTree KEYWORD2
23
26
 
package/library.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "type": "git",
7
7
  "url": "https://github.com/Alteriom/painlessMesh"
8
8
  },
9
- "version": "1.9.20",
9
+ "version": "1.10.0",
10
10
  "frameworks": [
11
11
  "arduino"
12
12
  ],
@@ -1,5 +1,5 @@
1
1
  name=Alteriom PainlessMesh
2
- version=1.9.20
2
+ version=1.10.0
3
3
  author=Coopdis,Scotty Franzyshen,Edwin van Leeuwen,Germán Martín,Maximilian Schwarz,Doanh Doanh,Alteriom
4
4
  maintainer=Alteriom
5
5
  sentence=A painless way to setup a mesh with ESP8266 and ESP32 devices with Alteriom extensions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteriom/painlessmesh",
3
- "version": "1.9.20",
3
+ "version": "1.10.0",
4
4
  "description": "painlessMesh is a user-friendly library for creating mesh networks with ESP8266 and ESP32 devices. This Alteriom fork includes additional packages for sensor data (SensorPackage), device commands (CommandPackage), and status monitoring (StatusPackage). It handles routing and network management automatically, so you can focus on your application. The library uses JSON-based messaging and syncs time across all nodes, making it ideal for coordinated behaviour like synchronized light displays or sensor networks reporting to a central node.",
5
5
  "keywords": [
6
6
  "arduino",
@@ -29,10 +29,10 @@
29
29
  /**
30
30
  * @brief AlteriomPainlessMesh library version information
31
31
  */
32
- #define ALTERIOM_PAINLESS_MESH_VERSION "1.9.20"
32
+ #define ALTERIOM_PAINLESS_MESH_VERSION "1.10.0"
33
33
  #define ALTERIOM_PAINLESS_MESH_VERSION_MAJOR 1
34
- #define ALTERIOM_PAINLESS_MESH_VERSION_MINOR 9
35
- #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 20
34
+ #define ALTERIOM_PAINLESS_MESH_VERSION_MINOR 10
35
+ #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 0
36
36
 
37
37
  /**
38
38
  * @brief Library description and usage information
@@ -817,8 +817,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
817
817
  * TCP Connection Retry:
818
818
  * The TCP connection now includes automatic retry with exponential backoff.
819
819
  * If the initial connection fails (error -14 ERR_CONN or other errors),
820
- * the system will retry up to TCP_CONNECT_MAX_RETRIES times before
821
- * triggering a full WiFi reconnection cycle. This helps handle:
820
+ * the system will retry up to the configured maxRetries times before
821
+ * triggering a full WiFi reconnection cycle (see setTcpRetryConfig()).
822
+ * This helps handle:
822
823
  * - Timing issues where TCP server is not ready immediately
823
824
  * - Network stack stabilization after IP acquisition
824
825
  * - Transient network conditions
@@ -842,8 +843,11 @@ class Mesh : public painlessmesh::Mesh<Connection> {
842
843
  // This helps prevent error -14 (ERR_CONN) by allowing the network stack
843
844
  // and TCP server to be fully ready. The delay is added via task scheduler
844
845
  // to avoid blocking the event loop.
846
+ // The delay is read at schedule time, so calling setTcpRetryConfig()
847
+ // while a connect is already pending affects the next attempt, not the
848
+ // in-flight one.
845
849
  this->addTask(
846
- painlessmesh::tcp::TCP_CONNECT_STABILIZATION_DELAY_MS, TASK_ONCE,
850
+ this->getTcpRetryConfig().stabilizationDelayMs, TASK_ONCE,
847
851
  [this, targetIP, targetPort]() {
848
852
  // Verify WiFi is still connected after the delay
849
853
  if (WiFi.status() != WL_CONNECTED || !WiFi.localIP()) {
@@ -1962,14 +1966,31 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1962
1966
  for (int i = 0; i < 100; i++) { delay(10); yield(); }
1963
1967
  Log(STARTUP, "[OK] Takeover announcement sent on channel %d\n", _meshChannel);
1964
1968
 
1965
- // Save current mesh configuration to restore if bridge init fails
1969
+ // Save current mesh configuration to restore if bridge init fails.
1970
+ // The copies below are captured by value into the deferred lambda so the
1971
+ // stop() call inside it cannot clear/mutate these members before the
1972
+ // reinit reads them. (The Mesh object itself outlives the lambda; this
1973
+ // protects against member mutation, not object lifetime.)
1974
+ // TODO(#373 follow-up): if the mesh owns its scheduler
1975
+ // (!isExternalScheduler), stop() deletes mScheduler and savedScheduler
1976
+ // dangles before initAsBridge() uses it. Pre-existing limitation —
1977
+ // bridge promotion requires a user-supplied scheduler.
1966
1978
  uint8_t savedChannel = _meshChannel;
1979
+ TSTRING savedMeshSSID = _meshSSID;
1980
+ TSTRING savedMeshPassword = _meshPassword;
1981
+ TSTRING savedRouterSSID = routerSSID;
1982
+ TSTRING savedRouterPassword = routerPassword;
1983
+ Scheduler *savedScheduler = mScheduler;
1984
+ auto savedBridgeRoleChangedCallback = bridgeRoleChangedCallback;
1967
1985
 
1968
1986
  // CRITICAL FIX: Schedule the stop/reinit work to run after current task completes
1969
1987
  // This prevents use-after-free crash when stop() clears taskList while
1970
1988
  // evaluateElection() task is still executing
1971
1989
  // Use minimal delay to allow current task to complete first
1972
- this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE, [this, savedChannel]() {
1990
+ this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE,
1991
+ [this, savedChannel, savedMeshSSID, savedMeshPassword,
1992
+ savedRouterSSID, savedRouterPassword, savedScheduler,
1993
+ savedBridgeRoleChangedCallback]() {
1973
1994
  using namespace logger;
1974
1995
 
1975
1996
  Log(STARTUP, "Executing bridge promotion (stop/reinit cycle)\n");
@@ -1981,18 +2002,17 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1981
2002
 
1982
2003
  // initAsBridge always returns true: bridge mesh functionality is active
1983
2004
  // regardless of router connection status (router connection is opportunistic)
1984
- this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
1985
- mScheduler, _meshPort);
2005
+ this->initAsBridge(savedMeshSSID, savedMeshPassword, savedRouterSSID,
2006
+ savedRouterPassword, savedScheduler, _meshPort);
1986
2007
 
1987
2008
  lastRoleChangeTime = millis();
1988
2009
 
1989
2010
  Log(STARTUP, "[OK] Bridge promotion complete on channel %d\n", _meshChannel);
1990
2011
 
1991
2012
  // Notify via callback
1992
- // Use explicit TSTRING construction to ensure string lifetime safety
1993
- if (bridgeRoleChangedCallback) {
2013
+ if (savedBridgeRoleChangedCallback) {
1994
2014
  static const TSTRING reason = "Election winner - best router signal";
1995
- bridgeRoleChangedCallback(true, reason);
2015
+ savedBridgeRoleChangedCallback(true, reason);
1996
2016
  }
1997
2017
 
1998
2018
  // Note: The initial takeover announcement was already sent earlier
@@ -2065,14 +2085,27 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2065
2085
  Log(CONNECTION,
2066
2086
  "Scheduling stop/reinit (async to avoid task corruption)\n");
2067
2087
 
2068
- // Save current mesh configuration
2088
+ // Save current mesh configuration. Captured by value into the deferred
2089
+ // lambda so stop() cannot clear/mutate these members before the reinit
2090
+ // reads them (see the matching comment in promoteToBridge(); same
2091
+ // TODO(#373 follow-up) about savedScheduler and internal schedulers
2092
+ // applies here).
2069
2093
  uint8_t savedChannel = _meshChannel;
2094
+ TSTRING savedMeshSSID = _meshSSID;
2095
+ TSTRING savedMeshPassword = _meshPassword;
2096
+ TSTRING savedRouterSSID = routerSSID;
2097
+ TSTRING savedRouterPassword = routerPassword;
2098
+ Scheduler *savedScheduler = mScheduler;
2099
+ auto savedBridgeRoleChangedCallback = bridgeRoleChangedCallback;
2070
2100
 
2071
2101
  // CRITICAL FIX: Schedule the stop/reinit work to run after current task completes
2072
2102
  // This prevents use-after-free crash when stop() clears taskList while
2073
2103
  // the retry task is still executing
2074
2104
  // Use minimal delay to allow current task to complete first
2075
- this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE, [this, savedChannel]() {
2105
+ this->addTask(ASYNC_PROMOTION_DELAY_MS, TASK_ONCE,
2106
+ [this, savedChannel, savedMeshSSID, savedMeshPassword,
2107
+ savedRouterSSID, savedRouterPassword, savedScheduler,
2108
+ savedBridgeRoleChangedCallback]() {
2076
2109
  using namespace logger;
2077
2110
 
2078
2111
  Log(CONNECTION, "Executing isolated bridge promotion (stop/reinit cycle)\n");
@@ -2084,8 +2117,8 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2084
2117
 
2085
2118
  // initAsBridge always returns true: bridge mesh functionality is active
2086
2119
  // regardless of router connection status (router connection is opportunistic)
2087
- this->initAsBridge(_meshSSID, _meshPassword, routerSSID, routerPassword,
2088
- mScheduler, _meshPort);
2120
+ this->initAsBridge(savedMeshSSID, savedMeshPassword, savedRouterSSID,
2121
+ savedRouterPassword, savedScheduler, _meshPort);
2089
2122
 
2090
2123
  // Reset retry counter
2091
2124
  _isolatedBridgeRetryAttempts = 0;
@@ -2095,10 +2128,9 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2095
2128
  _meshChannel);
2096
2129
 
2097
2130
  // Notify via callback
2098
- // Use explicit TSTRING construction to ensure string lifetime safety
2099
- if (bridgeRoleChangedCallback) {
2131
+ if (savedBridgeRoleChangedCallback) {
2100
2132
  static const TSTRING reason = "Isolated node promoted to bridge";
2101
- bridgeRoleChangedCallback(true, reason);
2133
+ savedBridgeRoleChangedCallback(true, reason);
2102
2134
  }
2103
2135
 
2104
2136
  // Note: Bridge status announcement will be sent automatically by
@@ -5,8 +5,8 @@
5
5
  * @file painlessMesh.h
6
6
  * @brief Main header file for Alteriom painlessMesh library
7
7
  *
8
- * @version 1.9.20
9
- * @date 2025-12-21
8
+ * @version 1.10.0
9
+ * @date 2026-08-12
10
10
  *
11
11
  * painlessMesh is a user-friendly library for creating mesh networks with
12
12
  * ESP8266 and ESP32 devices. This Alteriom fork includes additional packages
@@ -2,6 +2,15 @@
2
2
  #define _TASK_PRIORITY // Support for layered scheduling priority
3
3
  #define _TASK_STD_FUNCTION // Support for std::function (ESP8266 and ESP32)
4
4
  // Required for painlessMesh lambda callbacks
5
+ #define _TASK_SELF_DESTRUCT // Scheduler-managed deletion of heap-allocated
6
+ // one-shot tasks. The Scheduler deletes the Task
7
+ // in execute(), after disable() has returned, so
8
+ // a task never has to delete itself from inside
9
+ // its own onDisable callback (which is a
10
+ // use-after-free: Task::disable() writes to the
11
+ // task object after onDisable returns). Used by
12
+ // scheduleAsyncClientDeletion() in
13
+ // connection.hpp. See issue #373.
5
14
 
6
15
  // NOTE: _TASK_THREAD_SAFE is currently DISABLED due to incompatibility
7
16
  // with _TASK_STD_FUNCTION in TaskScheduler v4.0.x
@@ -37,7 +37,10 @@ class ReceiveBuffer {
37
37
  do {
38
38
  auto len = strnlen(data_ptr, length);
39
39
  do {
40
- auto read_len = (std::min)(len, buf.length);
40
+ // Reserve one byte for the '\0' terminator below: read_len may be at
41
+ // most buf.length - 1, otherwise buf.buffer[read_len] writes one
42
+ // byte past the end of the buffer (caught by the ASan CI job).
43
+ auto read_len = (std::min)(len, buf.length - 1);
41
44
  memcpy(buf.buffer, data_ptr, read_len);
42
45
  buf.buffer[read_len] = '\0';
43
46
  auto newBuffer = T(buf.buffer);
@@ -26,10 +26,21 @@
26
26
  // Enable OTA support
27
27
  #define PAINLESSMESH_ENABLE_OTA
28
28
 
29
- // Minimum free memory, besides here all packets in queue are discarded.
29
+ // NOTE: `MIN_FREE_MEMORY` and `MAX_MESSAGE_QUEUE` are kept as deprecated
30
+ // no-op compatibility macros. The library does not read either macro:
31
+ // the auto-flushing message queue they were meant to tune never landed
32
+ // (see #385, PR #383 review). `MessageQueue`
33
+ // (`painlessmesh/message_queue.hpp`) is a manual priority buffer with
34
+ // its own per-instance `maxSize` argument. Their historical default
35
+ // values are preserved so downstream code that referenced them keeps
36
+ // its prior behavior.
37
+ #ifndef MIN_FREE_MEMORY
30
38
  #define MIN_FREE_MEMORY 4000
31
- // MAX number of unsent messages in queue. Newer messages are discarded
39
+ #endif
40
+
41
+ #ifndef MAX_MESSAGE_QUEUE
32
42
  #define MAX_MESSAGE_QUEUE 50
43
+ #endif
33
44
 
34
45
  #define NODE_TIMEOUT 10 * TASK_SECOND
35
46
  #define SCAN_INTERVAL 30 * TASK_SECOND // AP scan period in ms
@@ -123,12 +123,17 @@ inline void scheduleAsyncClientDeletion(Scheduler* scheduler, AsyncClient* clien
123
123
  delete client;
124
124
  });
125
125
 
126
- // Self-cleanup: after execution, remove from scheduler and delete the Task.
127
- // OnDisable fires after a TASK_ONCE task completes its single iteration.
128
- cleanupTask->setOnDisable([cleanupTask, scheduler]() {
129
- scheduler->deleteTask(*cleanupTask);
130
- delete cleanupTask;
131
- });
126
+ // Task cleanup (issue #373): the task must NOT delete itself from inside
127
+ // its own onDisable callback. TaskScheduler's Task::disable() keeps
128
+ // writing to the task object (iScheduler->iCurrent) after onDisable
129
+ // returns, so a self-delete there is a guaranteed use-after-free — this
130
+ // was crashing nodes on every connection teardown.
131
+ // Instead we mark the task self-destructing (_TASK_SELF_DESTRUCT, enabled
132
+ // in painlessTaskOptions.h): after the single iteration completes, the
133
+ // Scheduler itself disables the task, unlinks it from its chain, and
134
+ // deletes it — all from within Scheduler::execute(), safely outside the
135
+ // disable() call stack.
136
+ cleanupTask->setSelfDestruct(true);
132
137
 
133
138
  scheduler->addTask(*cleanupTask);
134
139
  cleanupTask->enableDelayed();
@@ -160,13 +165,28 @@ class BufferedConnection
160
165
  using namespace logger;
161
166
  Log.remote("~BufferedConnection");
162
167
  this->close();
163
- if (!client->freeable()) {
164
- client->close(true);
165
- }
168
+ // Always call client->close() here, unconditionally - do NOT guard this
169
+ // behind client->freeable(). freeable() can return true while _pcb is
170
+ // still a valid, non-null pointer (e.g. pcb->state == CLOSED but not yet
171
+ // reclaimed by lwIP). If we skip close() in that case, _pcb stays
172
+ // non-null for the entire TCP_CLIENT_CLEANUP_DELAY_MS+ deferred-deletion
173
+ // window below - during which lwIP's own internal timers (e.g. TIME_WAIT
174
+ // expiry) can silently free/recycle that pcb without ever notifying
175
+ // AsyncClient (the tcp_err callback only fires on abnormal termination,
176
+ // not on routine timer-driven pcb reclamation). The deferred delete then
177
+ // finds a stale-but-non-null _pcb and tries to close/free it a second
178
+ // time, corrupting the heap (observed as heap_caps_free/memp_free
179
+ // assertion failures and wild-pointer crashes inside tcp_arg(), all
180
+ // several seconds after the connection actually died).
181
+ // close() internally handles "nothing to do" safely (AsyncTCP checks
182
+ // _pcb/*pcb before touching lwIP state), and reliably nulls _pcb via its
183
+ // synchronous tcpip_api_call round-trip - so calling it unconditionally
184
+ // here, right at destruction time, closes this exposure window entirely.
185
+ client->close();
166
186
  // Note: client->abort() removed - calling it before deferred deletion
167
187
  // can leave the client in an inconsistent state where AsyncTCP is still
168
- // trying to clean up the aborted connection. The close() and close(true)
169
- // calls above are sufficient for connection termination.
188
+ // trying to clean up the aborted connection. The close() call above is
189
+ // sufficient for connection termination.
170
190
  // See: AsyncTCP best practices - abort() should only be called immediately
171
191
  // before delete, not before a deferred deletion.
172
192
 
@@ -344,7 +344,7 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
344
344
  (*conn)->close();
345
345
  this->eraseClosedConnections();
346
346
  }
347
- plugin::PackageHandler<T>::stop();
347
+ plugin::PackageHandler<T>::stop(mScheduler);
348
348
 
349
349
  newConnectionCallbacks.clear();
350
350
  droppedConnectionCallbacks.clear();
@@ -1394,6 +1394,32 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1394
1394
  internetRetryDelay = delayMs;
1395
1395
  }
1396
1396
 
1397
+ /**
1398
+ * Configure TCP connection retry behaviour
1399
+ *
1400
+ * Lets latency-sensitive, high-reliability or battery-powered deployments
1401
+ * pick their own retry envelope. Values outside safe operating bounds are
1402
+ * clamped - see painlessmesh::tcp::clampTcpRetryConfig(). Read the applied
1403
+ * (post-clamp) values back with getTcpRetryConfig().
1404
+ *
1405
+ * The defaults reproduce the previous hardcoded behaviour exactly, so
1406
+ * existing sketches that never call this see no change.
1407
+ *
1408
+ * @param config Retry parameters to apply
1409
+ */
1410
+ void setTcpRetryConfig(const painlessmesh::tcp::TcpRetryConfig &config) {
1411
+ tcpRetryConfig = painlessmesh::tcp::clampTcpRetryConfig(config);
1412
+ }
1413
+
1414
+ /**
1415
+ * Get the active TCP retry configuration
1416
+ *
1417
+ * @return The configuration currently in effect, after clamping
1418
+ */
1419
+ painlessmesh::tcp::TcpRetryConfig getTcpRetryConfig() const {
1420
+ return tcpRetryConfig;
1421
+ }
1422
+
1397
1423
  /**
1398
1424
  * Get number of pending Internet requests
1399
1425
  *
@@ -2060,16 +2086,34 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2060
2086
  //
2061
2087
 
2062
2088
  /**
2063
- * Enable or disable message queueing for offline mode
2064
- *
2065
- * When enabled, messages can be queued when Internet is unavailable
2066
- * and automatically flushed when connection is restored.
2067
- *
2089
+ * Enable or disable the manual message queue for offline mode
2090
+ *
2091
+ * When enabled, allocates a `MessageQueue` that your application can
2092
+ * push into while an upstream (MQTT/HTTP/etc.) is unreachable. The
2093
+ * mesh does NOT flush the queue automatically and does NOT observe
2094
+ * connectivity changes — the app is responsible for detecting when
2095
+ * to drain it via `flushMessageQueue()` + `removeQueuedMessage()`.
2096
+ *
2097
+ * Wire it to your own connectivity signal, for example
2098
+ * `onLocalInternetChanged` on the bridge node or
2099
+ * `onBridgeStatusChanged` on downstream nodes.
2100
+ *
2068
2101
  * @param enabled True to enable queueing, false to disable
2069
2102
  * @param maxSize Maximum number of messages in queue (default 1000)
2070
- *
2103
+ *
2071
2104
  * \code
2072
2105
  * mesh.enableMessageQueue(true, 500); // Enable with 500 message capacity
2106
+ *
2107
+ * mesh.onLocalInternetChanged([&mesh](bool available) {
2108
+ * if (available) {
2109
+ * auto messages = mesh.flushMessageQueue();
2110
+ * for (auto& msg : messages) {
2111
+ * if (sendToCloud(msg.payload, msg.destination)) {
2112
+ * mesh.removeQueuedMessage(msg.id);
2113
+ * }
2114
+ * }
2115
+ * }
2116
+ * });
2073
2117
  * \endcode
2074
2118
  */
2075
2119
  void enableMessageQueue(bool enabled, uint32_t maxSize = 1000) {
@@ -2084,11 +2128,13 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2084
2128
  }
2085
2129
 
2086
2130
  /**
2087
- * Queue a message with priority for later delivery
2088
- *
2089
- * Use this to queue critical messages when Internet is unavailable.
2090
- * Messages are automatically delivered when connection is restored.
2091
- *
2131
+ * Queue a message with priority for later manual delivery
2132
+ *
2133
+ * Use this to buffer critical messages when your upstream
2134
+ * (MQTT/HTTP/etc.) is unavailable. The library does NOT deliver these
2135
+ * on its own — your app must call `flushMessageQueue()` and
2136
+ * `removeQueuedMessage()` once its own connectivity check clears.
2137
+ *
2092
2138
  * @param payload Message content to queue
2093
2139
  * @param destination Optional destination metadata (e.g., MQTT topic, HTTP endpoint)
2094
2140
  * @param priority Message priority (default: PRIORITY_NORMAL)
@@ -2115,16 +2161,14 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
2115
2161
  }
2116
2162
 
2117
2163
  /**
2118
- * Flush all queued messages
2119
- *
2120
- * Attempts to send all queued messages. This is typically called
2121
- * automatically when Internet connection is restored, but can be
2122
- * called manually.
2123
- *
2124
- * Note: This returns the messages for the application to send.
2125
- * The application is responsible for actually transmitting them
2126
- * and calling removeQueuedMessage() when successful.
2127
- *
2164
+ * Return a snapshot of all queued messages for manual delivery
2165
+ *
2166
+ * This does NOT transmit anything and does NOT clear the queue. The
2167
+ * application is responsible for actually sending each message and
2168
+ * calling `removeQueuedMessage(id)` for each one that succeeded.
2169
+ * Messages left in the queue remain available for retry on the next
2170
+ * flush.
2171
+ *
2128
2172
  * @return Vector of queued messages to send
2129
2173
  *
2130
2174
  * \code
@@ -3262,6 +3306,13 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
3262
3306
  uint32_t internetRetryDelay = 1000; // Default 1 second base delay
3263
3307
  bool sendToInternetEnabled = false;
3264
3308
 
3309
+ // TCP connect retry parameters, see setTcpRetryConfig().
3310
+ // Public because tcp::connect<Connection, wifi::Mesh> reads it but is NOT a
3311
+ // friend: the friend declaration below names connect<T, Mesh<T>>, whereas
3312
+ // wifi::Mesh::tcpConnect() instantiates connect<Connection, wifi::Mesh> - a
3313
+ // different specialisation. Same reason as the public: sections above.
3314
+ painlessmesh::tcp::TcpRetryConfig tcpRetryConfig;
3315
+
3265
3316
  friend T;
3266
3317
  friend void onDataCb(void *, AsyncClient *, void *, size_t);
3267
3318
  friend void tcpSentCb(void *, AsyncClient *, size_t, uint32_t);