@alteriom/painlessmesh 1.9.9 → 1.9.11

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.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
19
 
20
20
  - TBD
21
21
 
22
+ ## [1.9.11] - 2025-12-18
23
+
24
+ ### Fixed
25
+
26
+ - **Hard Reset on Bridge Promotion - Unsafe addTask After stop/reinit** - Fixed ESP32/ESP8266 hard resets (Guru Meditation Error: Load access fault) immediately after bridge promotion in both isolated and election winner paths
27
+ - **Root Cause**: Calling `addTask()` immediately after `stop()/initAsBridge()` cycle accessed unstable internal task scheduling structures before they were fully reinitialized in the new context
28
+ - **Symptom**: Device crashes with "Load access fault" at MTVAL 0xbaad59d4 (freed memory marker) immediately after "🎯 PROMOTED TO BRIDGE" message when node becomes bridge through either isolated promotion or election
29
+ - **Solution**: Removed redundant task scheduling calls that were unsafe after stop/reinit
30
+ - Removed `addTask()` call in `attemptIsolatedBridgePromotion()` (line 1947)
31
+ - Removed `addTask()` call in `promoteToBridge()` (line 1822)
32
+ - Relied on existing `initBridgeStatusBroadcast()` infrastructure which safely handles announcements
33
+ - Used explicit `TSTRING` construction in callback invocations for string lifetime safety
34
+ - **Why Safe**: The removed tasks were redundant - `initBridgeStatusBroadcast()` (called by `initAsBridge()`) already sends immediate and periodic bridge status broadcasts (lines 1277-1280), and election winner path sends initial takeover announcement before stop/reinit (line 1771)
35
+ - **Testing**: All test suites pass (1000+ assertions), including bridge election and promotion tests
36
+ - **Documentation**: Added ISSUE_HARD_RESET_BRIDGE_PROMOTION_FIX.md with detailed analysis of both affected code paths
37
+ - **Impact**: Eliminates critical crash during bridge promotion, allows stable bridge failover operation in both isolated and competitive election scenarios
38
+
39
+ - **Bridge Failover & sendToInternet Retry Connectivity** - Fixed heap corruption and request timeouts when using bridge_failover with sendToInternet() during connection instability
40
+ - **Root Cause**: `retryInternetRequest()` did not check mesh connectivity before attempting retry, causing routing attempts through unreachable gateways during bridge disconnection
41
+ - **Symptom**: Nodes experience timeouts, heap corruption ("CORRUPT HEAP: Bad head at 0x40831da0"), and system instability during bridge failover cycles when messages are queued via sendToInternet()
42
+ - **Solution**: Added `hasActiveMeshConnections()` check at start of `retryInternetRequest()`
43
+ - Retry only proceeds if mesh connections are active
44
+ - If disconnected, reschedules retry instead of attempting to route
45
+ - Prevents routing to unreachable gateways during temporary disconnection
46
+ - Maintains existing retry logic and exponential backoff
47
+ - **Testing**: Added comprehensive test coverage (catch_sendtointernet_retry_no_mesh.cpp) with 31 assertions validating disconnected retry scenarios
48
+ - **Documentation**: Added BRIDGE_FAILOVER_RETRY_FIX.md with detailed analysis and usage notes
49
+ - **Impact**: Fixes critical stability issue during bridge failover, enabling reliable sendToInternet() usage in production deployments with unstable connections
50
+
51
+ - **Hard Reset During sendToInternet - Serialized AsyncClient Deletion** - Fixed ESP32/ESP8266 hard resets caused by heap corruption when multiple AsyncClient cleanup operations execute concurrently
52
+ - **Root Cause**: When multiple connections fail in rapid succession (e.g., during sendToInternet operations, mesh topology changes, or bridge failover), all AsyncClient deletions were scheduled with the same 1000ms delay, causing them to execute concurrently. The AsyncTCP library's internal cleanup routines cannot handle concurrent operations, leading to heap corruption.
53
+ - **Symptom**: Device crashes with "CORRUPT HEAP: Bad head at 0x40831da0. Expected 0xabba1234 got 0x4081faa4" even with 1000ms cleanup delay. Error occurs when multiple "Deferred cleanup of AsyncClient" messages appear nearly simultaneously.
54
+ - **Solution**: Implemented serialized deletion with 250ms spacing between consecutive AsyncClient deletions
55
+ - Added `TCP_CLIENT_DELETION_SPACING_MS` constant (250ms) to ensure deletions don't overlap
56
+ - Added global `lastScheduledDeletionTime` tracker to coordinate deletion timing
57
+ - Implemented `scheduleAsyncClientDeletion()` function that calculates proper spacing
58
+ - Updated `BufferedConnection` destructor and tcp.hpp error handlers to use centralized scheduler
59
+ - Ensures each AsyncClient deletion completes before the next one starts
60
+ - Handles millis() rollover and multiple concurrent deletion requests
61
+ - **Performance Impact**:
62
+ - Single deletion: No change (1000ms)
63
+ - Multiple concurrent deletions: Spaced by 250ms each (total spread <1 second for typical scenarios)
64
+ - High-churn scenario (10 failures): Spread over ~3 seconds (still acceptable)
65
+ - **Testing**: All test suites pass (1000+ assertions), including new deletion spacing tests (47 assertions in tcp_retry)
66
+ - **Documentation**: Added ISSUE_HARD_RESET_SENDTOINTERNET_SERIALIZED_DELETION_FIX.md with detailed analysis
67
+ - **Impact**: Fixes critical stability issue in production deployments with high connection churn, particularly affecting sendToInternet and bridge failover scenarios
68
+
69
+ ## [1.9.10] - 2025-12-15
70
+
71
+ ### Fixed
72
+
73
+ - **TCP Connection Retry Immediate Execution** - Fixed mesh connection failures where TCP retries executed immediately instead of with exponential backoff delays
74
+ - **Root Cause**: `PackageHandler::addTask()` was calling `task->enable()` instead of `task->enableDelayed()` for one-shot delayed tasks, causing immediate execution
75
+ - **Symptom**: Nodes unable to establish mesh connection - TCP error -14 (ERR_CONN) with all retry attempts happening immediately rather than with 1s, 2s, 4s, 8s, 8s delays
76
+ - **Solution**: Modified `PackageHandler::addTask()` to use `enableDelayed()` for `TASK_ONCE` tasks with intervals > 0
77
+ - Retry tasks now properly wait for their scheduled delays before executing
78
+ - Exponential backoff mechanism now works as designed (total ~23s before WiFi reconnection)
79
+ - Reduces network congestion from multiple simultaneously retrying nodes
80
+ - **Impact**: Enables successful mesh connection establishment with proper retry timing
81
+ - **Files Modified**:
82
+ - `src/painlessmesh/plugin.hpp` (lines 231, 239, 245-251)
83
+ - `docs/troubleshooting/common-issues.md` (lines 206-212)
84
+ - `test/catch/catch_delayed_task_execution.cpp` (new test file)
85
+ - `ISSUE_TCP_RETRY_FIX.md` (new documentation)
86
+
87
+ - **Hard Reset on Bridge Failover Election Winner** - Fixed ESP32 hard reset (Guru Meditation Error: Load access fault) when node becomes bridge after election
88
+ - **Root Cause**: The `bridgeRoleChangedCallback` signature used pass-by-value for the String parameter (`TSTRING reason`), causing temporary String object creation from const char* literals. On memory-constrained ESP32/ESP8266, this could trigger heap allocation failures and memory access faults
89
+ - **Symptom**: Device crashes with "Guru Meditation Error: Core 0 panic'ed (Load access fault)" immediately after logging "🎯 PROMOTED TO BRIDGE: Election winner - best router signal"
90
+ - **Solution**: Changed callback signature to use const reference (`const TSTRING& reason`) instead of pass-by-value
91
+ - Eliminates unnecessary String object copying and temporary creation
92
+ - Reduces memory pressure during callback invocation
93
+ - String literals are now directly bound to const references without heap allocation
94
+ - **Impact**: Eliminates hard resets during bridge promotion, allows stable failover operation
95
+ - **Breaking Change**: Users must update their callback function signatures from `void callback(bool, String)` to `void callback(bool, const String&)`
96
+ - **Files Modified**:
97
+ - `src/arduino/wifi.hpp` (lines 970, 2326)
98
+ - `examples/bridge_failover/bridge_failover.ino` (line 147)
99
+ - `README.md` (line 171)
100
+ - `USER_GUIDE.md` (line 731)
101
+
22
102
  ## [1.9.9] - 2025-12-14
23
103
 
24
104
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div align="center">
6
6
 
7
- **Version 1.9.9** - Latest release with AsyncClient cleanup fixes for improved stability
7
+ **Version 1.9.11** - Latest release with critical stability fixes for bridge promotion and AsyncClient cleanup
8
8
 
9
9
  [![CI/CD Pipeline](https://github.com/Alteriom/painlessMesh/actions/workflows/ci.yml/badge.svg)](https://github.com/Alteriom/painlessMesh/actions/workflows/ci.yml)
10
10
  [![Documentation](https://github.com/Alteriom/painlessMesh/actions/workflows/docs.yml/badge.svg)](https://github.com/Alteriom/painlessMesh/actions/workflows/docs.yml)
@@ -168,7 +168,7 @@ mesh.setRouterCredentials(ROUTER_SSID, ROUTER_PASSWORD);
168
168
  mesh.enableBridgeFailover(true);
169
169
  mesh.onBridgeRoleChanged(&bridgeRoleCallback);
170
170
 
171
- void bridgeRoleCallback(bool isBridge, String reason) {
171
+ void bridgeRoleCallback(bool isBridge, const String& reason) {
172
172
  if (isBridge) {
173
173
  Serial.printf("🎯 Promoted to bridge: %s\n", reason.c_str());
174
174
  }
@@ -205,11 +205,12 @@ For Arduino IDE, install manually from: https://github.com/ESP32Async/AsyncTCP
205
205
 
206
206
  #### 2. Built-in Retry Mechanism
207
207
  painlessMesh now includes automatic TCP connection retry with the following behavior:
208
- - Up to 3 retry attempts with 500ms delay between each
209
- - 100ms stabilization delay after IP acquisition before first connection attempt
210
- - Full WiFi reconnection only triggered after all retries are exhausted
208
+ - Up to 6 total connection attempts (initial + 5 retries)
209
+ - Exponential backoff delays between retries: 1s, 2s, 4s, 8s, 8s (total ~23s)
210
+ - 500ms stabilization delay after IP acquisition before first connection attempt
211
+ - Full WiFi reconnection with 10s delay only triggered after all retries are exhausted
211
212
 
212
- This helps handle transient timing issues automatically.
213
+ This exponential backoff helps handle transient timing issues automatically while reducing network congestion from multiple retrying nodes.
213
214
 
214
215
  #### 3. Check Node Resource Usage
215
216
  Monitor memory and ensure nodes aren't overloaded:
@@ -11,6 +11,8 @@ Regular mesh nodes do NOT have direct IP routing to the Internet. They only comm
11
11
  **To send data to the Internet from a regular mesh node, you must:**
12
12
 
13
13
  1. **Use `sendToInternet()`** - Routes data through a gateway node
14
+ - Call `mesh.enableSendToInternet()` on ALL nodes after mesh.init()
15
+ - This enables both sending (regular nodes) AND routing (bridge nodes)
14
16
  2. **Use `initAsSharedGateway()`** - Configures all nodes with direct router access (requires router credentials - see below)
15
17
  3. **Send mesh messages to the bridge** - Bridge node handles Internet communication
16
18
 
@@ -39,8 +39,8 @@
39
39
  //
40
40
  // To send data to the Internet from a regular node:
41
41
  // 1. Use mesh.sendToInternet() to route through a gateway
42
- // - Call mesh.enableSendToInternet() on the sending node after mesh.init()
43
- // - Bridge nodes (this example) do NOT need enableSendToInternet() - they route automatically
42
+ // - Call mesh.enableSendToInternet() AFTER mesh.init() on SENDING nodes only
43
+ // - Bridge nodes automatically handle routing via initAsBridge()
44
44
  // - See examples/sendToInternet/sendToInternet.ino for complete usage
45
45
  // 2. Use initAsSharedGateway() so all nodes have router access
46
46
  // NOTE: initAsSharedGateway() requires ROUTER credentials:
@@ -144,7 +144,7 @@ void bridgeStatusCallback(uint32_t bridgeNodeId, bool hasInternet) {
144
144
  }
145
145
  }
146
146
 
147
- void bridgeRoleCallback(bool isBridge, String reason) {
147
+ void bridgeRoleCallback(bool isBridge, const String& reason) {
148
148
  if (isBridge) {
149
149
  Serial.printf("🎯 PROMOTED TO BRIDGE: %s\n", reason.c_str());
150
150
  Serial.println("This node is now the primary bridge!");
@@ -223,6 +223,13 @@ void setup() {
223
223
  // mesh.setElectionRandomDelay(10000, 30000); // 10-30 seconds (default: 1-3 seconds)
224
224
  }
225
225
 
226
+ // NOTE: Bridge nodes do NOT need to call mesh.enableSendToInternet()
227
+ // The initAsBridge() method already sets up gateway routing via initGatewayInternetHandler()
228
+ // which handles incoming sendToInternet() requests from regular nodes.
229
+ //
230
+ // Only call enableSendToInternet() on nodes that will SEND requests (regular nodes).
231
+ // Bridge nodes only need to ROUTE requests, which is automatically configured.
232
+
226
233
  // Register callbacks
227
234
  mesh.onReceive(&receivedCallback);
228
235
  mesh.onNewConnection(&newConnectionCallback);
@@ -42,9 +42,9 @@
42
42
  // - Works as-is without any modifications needed!
43
43
  //
44
44
  // 2. SENDING NODE SETUP:
45
- // - Call mesh.enableSendToInternet() AFTER mesh.init() on nodes that will SEND requests
46
- // - Bridge nodes do NOT need to call enableSendToInternet() - they route automatically
47
- // - This example shows how to enable it in the setup() function below
45
+ // - Call mesh.enableSendToInternet() AFTER mesh.init() on nodes that SEND requests.
46
+ // - Bridge nodes automatically handle routing via initAsBridge().
47
+ // - This example shows how to enable it in the setup() function below.
48
48
  //
49
49
  // For Callmebot WhatsApp API:
50
50
  // - Get your API key from https://www.callmebot.com/blog/free-api-whatsapp-messages/
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.9",
9
+ "version": "1.9.11",
10
10
  "frameworks": [
11
11
  "arduino"
12
12
  ],
@@ -1,5 +1,5 @@
1
1
  name=Alteriom PainlessMesh
2
- version=1.9.9
2
+ version=1.9.11
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.9",
3
+ "version": "1.9.11",
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",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@alteriom/mqtt-schema": "^0.8.0",
43
- "@eslint/js": "^9.39.1",
43
+ "@eslint/js": "^9.39.2",
44
44
  "ajv": "^8.17.1",
45
45
  "ajv-formats": "^3.0.1",
46
46
  "prettier": "^3.7.4"
@@ -29,10 +29,10 @@
29
29
  /**
30
30
  * @brief AlteriomPainlessMesh library version information
31
31
  */
32
- #define ALTERIOM_PAINLESS_MESH_VERSION "1.9.9"
32
+ #define ALTERIOM_PAINLESS_MESH_VERSION "1.9.11"
33
33
  #define ALTERIOM_PAINLESS_MESH_VERSION_MAJOR 1
34
34
  #define ALTERIOM_PAINLESS_MESH_VERSION_MINOR 9
35
- #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 9
35
+ #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 11
36
36
 
37
37
  /**
38
38
  * @brief Library description and usage information
@@ -967,7 +967,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
967
967
  * @param callback Function to call when role changes
968
968
  */
969
969
  void onBridgeRoleChanged(
970
- std::function<void(bool isBridge, TSTRING reason)> callback) {
970
+ std::function<void(bool isBridge, const TSTRING& reason)> callback) {
971
971
  bridgeRoleChangedCallback = callback;
972
972
  }
973
973
 
@@ -1740,8 +1740,11 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1740
1740
  Log(STARTUP, "=== Becoming Bridge Node ===\n");
1741
1741
 
1742
1742
  // Store previous bridge (if any)
1743
- auto primaryBridge = this->getPrimaryBridge();
1744
- uint32_t previousBridgeId = primaryBridge ? primaryBridge->nodeId : 0;
1743
+ // SAFETY: Use getPrimaryGateway() which returns the nodeId value directly
1744
+ // instead of getPrimaryBridge() which returns a pointer to a vector element.
1745
+ // This avoids crashes from dangling pointers that can occur if the
1746
+ // knownBridges vector is modified between pointer retrieval and use.
1747
+ uint32_t previousBridgeId = this->getPrimaryGateway();
1745
1748
 
1746
1749
  // IMPORTANT: Send takeover announcement BEFORE switching channels
1747
1750
  // This ensures other nodes on the current channel receive the announcement
@@ -1809,38 +1812,24 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1809
1812
  Log(STARTUP, "✓ Bridge promotion complete on channel %d\n", _meshChannel);
1810
1813
 
1811
1814
  // Notify via callback
1815
+ // Use explicit TSTRING construction to ensure string lifetime safety
1812
1816
  if (bridgeRoleChangedCallback) {
1813
- bridgeRoleChangedCallback(true, "Election winner - best router signal");
1817
+ static const TSTRING reason = "Election winner - best router signal";
1818
+ bridgeRoleChangedCallback(true, reason);
1814
1819
  }
1815
1820
 
1816
- // Send a follow-up announcement on the new channel
1817
- // This helps nodes that have already switched channels to discover the new
1818
- // bridge Schedule it after a delay to ensure mesh is fully initialized
1819
- this->addTask(3000, TASK_ONCE, [this, previousBridgeId]() {
1820
- Log(STARTUP,
1821
- "Sending follow-up takeover announcement on new channel %d\n",
1822
- _meshChannel);
1823
- JsonDocument doc2;
1824
- JsonObject obj2 = doc2.to<JsonObject>();
1825
- obj2["type"] = protocol::BRIDGE_TAKEOVER;
1826
- obj2["from"] = this->nodeId;
1827
- obj2["routing"] = 2; // BROADCAST
1828
- obj2["previousBridge"] = previousBridgeId;
1829
- obj2["reason"] = "Election winner - best router signal";
1830
- obj2["routerRSSI"] = WiFi.RSSI();
1831
- obj2["timestamp"] = this->getNodeTime();
1832
- obj2["message_type"] = protocol::BRIDGE_TAKEOVER;
1833
-
1834
- String msg2;
1835
- serializeJson(doc2, msg2);
1836
-
1837
- // Send follow-up takeover using raw broadcast to preserve type
1838
- // BRIDGE_TAKEOVER
1839
- protocol::Variant variant2(msg2);
1840
- router::broadcast<protocol::Variant, Connection>(variant2, (*this), 0);
1841
-
1842
- Log(STARTUP, "✓ Follow-up takeover announcement sent\n");
1843
- });
1821
+ // Note: The initial takeover announcement was already sent earlier
1822
+ // before the channel switch. The follow-up announcement that was previously
1823
+ // scheduled here has been removed to avoid potential crashes from scheduling
1824
+ // tasks immediately after stop()/reinit cycle.
1825
+ //
1826
+ // The bridge status broadcast system (initialized by initAsBridge via
1827
+ // initBridgeStatusBroadcast) will continue to inform nodes about the new
1828
+ // bridge through periodic broadcasts. Nodes that switched channels will
1829
+ // discover the new bridge through these status broadcasts.
1830
+ Log(STARTUP,
1831
+ "Bridge takeover complete. Status broadcasts will announce bridge to "
1832
+ "network.\n");
1844
1833
  }
1845
1834
 
1846
1835
  /**
@@ -1936,16 +1925,21 @@ class Mesh : public painlessmesh::Mesh<Connection> {
1936
1925
  _meshChannel);
1937
1926
 
1938
1927
  // Notify via callback
1928
+ // Use explicit TSTRING construction to ensure string lifetime safety
1939
1929
  if (bridgeRoleChangedCallback) {
1940
- bridgeRoleChangedCallback(true, "Isolated node promoted to bridge");
1930
+ static const TSTRING reason = "Isolated node promoted to bridge";
1931
+ bridgeRoleChangedCallback(true, reason);
1941
1932
  }
1942
1933
 
1943
- // Send bridge status announcement to attract other nodes
1944
- this->addTask(3000, TASK_ONCE, [this]() {
1945
- Log(STARTUP, "Sending bridge status announcement on channel %d\n",
1946
- _meshChannel);
1947
- this->sendBridgeStatus();
1948
- });
1934
+ // Note: Bridge status announcement will be sent automatically by
1935
+ // initBridgeStatusBroadcast() which is called by initAsBridge().
1936
+ // The immediate broadcast is scheduled in that function, so we don't
1937
+ // need to schedule another one here. This avoids potential crashes from
1938
+ // scheduling tasks immediately after stop()/reinit cycle.
1939
+ // The initBridgeStatusBroadcast() also sets up periodic broadcasts.
1940
+ Log(STARTUP,
1941
+ "Bridge status announcement will be sent by bridge status broadcast "
1942
+ "system\n");
1949
1943
 
1950
1944
  return true; // Count as an attempt - we succeeded
1951
1945
  }
@@ -2323,7 +2317,7 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2323
2317
  ElectionState electionState = ELECTION_IDLE;
2324
2318
  uint32_t electionDeadline = 0;
2325
2319
  std::vector<BridgeCandidate> electionCandidates;
2326
- std::function<void(bool isBridge, TSTRING reason)> bridgeRoleChangedCallback;
2320
+ std::function<void(bool isBridge, const TSTRING& reason)> bridgeRoleChangedCallback;
2327
2321
 
2328
2322
  // Isolated bridge retry state and configuration
2329
2323
  uint8_t _isolatedBridgeRetryAttempts = 0;
@@ -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.9
9
- * @date 2025-12-14
8
+ * @version 1.9.11
9
+ * @date 2025-12-18
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
@@ -16,12 +16,110 @@ namespace tcp {
16
16
 
17
17
  // Delay before cleaning up failed AsyncClient after connection error or close
18
18
  // This prevents crashes when AsyncTCP library is still accessing the client internally
19
- // The AsyncTCP library may take a few hundred milliseconds to complete its internal cleanup
20
- static const uint32_t TCP_CLIENT_CLEANUP_DELAY_MS = 500; // 500ms delay before deleting AsyncClient
19
+ // The AsyncTCP library may take several hundred milliseconds to complete its internal cleanup
20
+ // When multiple connections are failing simultaneously (e.g., during mesh connection issues),
21
+ // the library needs even more time to safely process multiple cleanup operations
22
+ // Increased from 500ms to 1000ms to handle high-churn scenarios more reliably
23
+ static const uint32_t TCP_CLIENT_CLEANUP_DELAY_MS = 1000; // 1000ms delay before deleting AsyncClient
24
+
25
+ // Minimum spacing between consecutive AsyncClient deletions to prevent concurrent cleanup
26
+ // When multiple AsyncClients are deleted in rapid succession, the AsyncTCP library's
27
+ // internal cleanup routines can interfere with each other, causing heap corruption
28
+ // This spacing ensures each deletion completes before the next one begins
29
+ static const uint32_t TCP_CLIENT_DELETION_SPACING_MS = 250; // 250ms spacing between deletions
30
+
31
+ // Global state to track AsyncClient deletion scheduling
32
+ // This ensures deletions are spaced out even when multiple deletion requests arrive simultaneously
33
+ // Note: Thread safety is not required - ESP32/ESP8266 mesh runs single-threaded in Arduino framework
34
+ // All mesh operations occur in the main loop or scheduler callbacks, never concurrently
35
+ static uint32_t lastScheduledDeletionTime = 0; // Timestamp when last deletion was scheduled (milliseconds)
21
36
 
22
37
  // Shared buffer for reading/writing to the buffer
23
38
  static painlessmesh::buffer::temp_buffer_t shared_buffer;
24
39
 
40
+ /**
41
+ * Schedule deletion of an AsyncClient with proper spacing to prevent concurrent cleanups
42
+ *
43
+ * This function ensures that AsyncClient deletions are spaced out in time to prevent
44
+ * the AsyncTCP library's internal cleanup routines from interfering with each other.
45
+ *
46
+ * When multiple AsyncClient objects need to be deleted (e.g., during high connection churn
47
+ * or sendToInternet scenarios), scheduling them all with the same delay can cause them to
48
+ * execute concurrently, leading to heap corruption.
49
+ *
50
+ * This function maintains a global timestamp of when the last deletion was scheduled and
51
+ * calculates an appropriate delay for the new deletion to ensure adequate spacing.
52
+ *
53
+ * @param scheduler The task scheduler to use for scheduling the deletion
54
+ * @param client The AsyncClient pointer to delete
55
+ * @param logPrefix Prefix for the log message (e.g., "~BufferedConnection" or "tcp_err")
56
+ */
57
+ inline void scheduleAsyncClientDeletion(Scheduler* scheduler, AsyncClient* client, const char* logPrefix) {
58
+ using namespace logger;
59
+
60
+ if (!scheduler) {
61
+ // Fallback: If scheduler not available, delete immediately (risky)
62
+ Log(CONNECTION, "%s: No scheduler available, deleting AsyncClient immediately (risky)\n", logPrefix);
63
+ delete client;
64
+ return;
65
+ }
66
+
67
+ // Get current time in milliseconds
68
+ uint32_t currentTime = millis();
69
+
70
+ // Calculate the earliest time this deletion should execute
71
+ // Base delay: TCP_CLIENT_CLEANUP_DELAY_MS (1000ms)
72
+ uint32_t baseDelay = TCP_CLIENT_CLEANUP_DELAY_MS;
73
+
74
+ // Calculate when this deletion should execute relative to the last scheduled deletion
75
+ // If the last deletion was scheduled recently, we need to add additional spacing
76
+ uint32_t targetDeletionTime = currentTime + baseDelay;
77
+
78
+ // If there's a recent deletion scheduled, ensure we space out from it
79
+ if (lastScheduledDeletionTime > 0) {
80
+ // Calculate when the next deletion slot is available
81
+ uint32_t nextAvailableSlot = lastScheduledDeletionTime + TCP_CLIENT_DELETION_SPACING_MS;
82
+
83
+ // If our target deletion time is before the next available slot, push it out
84
+ // Handle millis() rollover: Use signed arithmetic to detect if nextAvailableSlot is "in the future"
85
+ // relative to targetDeletionTime. This works because:
86
+ // - If difference is positive and < 2^31: nextAvailableSlot is ahead, we need to wait
87
+ // - If difference is negative or > 2^31: nextAvailableSlot is in the past (or very far future after rollover), use targetDeletionTime
88
+ int32_t timeUntilSlot = (int32_t)(nextAvailableSlot - targetDeletionTime);
89
+ if (timeUntilSlot > 0 && timeUntilSlot < (int32_t)(1U << 30)) {
90
+ // nextAvailableSlot is reasonably soon in the future (< ~12 days), space from it
91
+ targetDeletionTime = nextAvailableSlot;
92
+ }
93
+ // else: lastScheduledDeletionTime is too old (> baseDelay+spacing), or rollover occurred
94
+ // In this case, just use targetDeletionTime (currentTime + baseDelay) and reset spacing
95
+ }
96
+
97
+ // Calculate the actual delay from now
98
+ uint32_t actualDelay = targetDeletionTime - currentTime;
99
+
100
+ // Update the last scheduled deletion time
101
+ lastScheduledDeletionTime = targetDeletionTime;
102
+
103
+ Log(CONNECTION, "%s: Scheduling AsyncClient deletion in %u ms (spaced from previous deletions)\n",
104
+ logPrefix, actualDelay);
105
+
106
+ // Schedule the deletion task
107
+ // Note: Task object is intentionally leaked to keep implementation simple
108
+ // This is acceptable because:
109
+ // 1. Connections are long-lived, destructor calls are infrequent
110
+ // 2. Task object is small (~32-64 bytes) vs preventing critical heap corruption
111
+ // 3. In typical deployments, memory impact is negligible (few KB over months)
112
+ // 4. Alternative cleanup patterns would add significant complexity
113
+ Task* cleanupTask = new Task(actualDelay * TASK_MILLISECOND, TASK_ONCE, [client, logPrefix]() {
114
+ using namespace logger;
115
+ Log(CONNECTION, "%s: Deferred cleanup of AsyncClient executing now\n", logPrefix);
116
+ delete client;
117
+ });
118
+
119
+ scheduler->addTask(*cleanupTask);
120
+ cleanupTask->enableDelayed();
121
+ }
122
+
25
123
  /**
26
124
  * Class that performs buffered read and write to the tcp connection
27
125
  * (asyncclient)
@@ -54,35 +152,9 @@ class BufferedConnection
54
152
  client->abort();
55
153
 
56
154
  // Defer deletion of the AsyncClient to prevent heap corruption
57
- // Deleting immediately can cause use-after-free issues when the AsyncTCP
58
- // library is still referencing the object internally during cleanup
155
+ // Use the centralized deletion scheduler to ensure proper spacing between deletions
59
156
  // See ISSUE_254_HEAP_CORRUPTION_FIX.md and ASYNCCLIENT_CLEANUP_FIX.md
60
- if (mScheduler) {
61
- // Capture client pointer by value for safe deferred deletion
62
- AsyncClient* clientToDelete = client;
63
-
64
- // Schedule deletion task with TCP_CLIENT_CLEANUP_DELAY_MS delay
65
- // This gives AsyncTCP library time to complete its internal cleanup
66
- // Note: Task object is intentionally leaked to keep implementation simple
67
- // This is acceptable because:
68
- // 1. Connections are long-lived, destructor calls are infrequent
69
- // 2. Task object is small (~32-64 bytes) vs preventing critical heap corruption
70
- // 3. In typical deployments, memory impact is negligible (few KB over months)
71
- // 4. Alternative cleanup patterns would add significant complexity
72
- Task* cleanupTask = new Task(TCP_CLIENT_CLEANUP_DELAY_MS * TASK_MILLISECOND, TASK_ONCE, [clientToDelete]() {
73
- using namespace logger;
74
- Log(CONNECTION, "~BufferedConnection: Deferred cleanup of AsyncClient\n");
75
- delete clientToDelete;
76
- });
77
-
78
- mScheduler->addTask(*cleanupTask);
79
- cleanupTask->enableDelayed();
80
- } else {
81
- // Fallback: If scheduler not available, delete immediately
82
- // This should only happen in test environments or edge cases
83
- Log(CONNECTION, "~BufferedConnection: No scheduler available, deleting AsyncClient immediately (risky)\n");
84
- delete client;
85
- }
157
+ scheduleAsyncClientDeletion(mScheduler, client, "~BufferedConnection");
86
158
  }
87
159
 
88
160
  void initialize(Scheduler *scheduler) {
@@ -1558,6 +1558,15 @@ class Mesh : public ntp::MeshTime, public plugin::PackageHandler<T> {
1558
1558
 
1559
1559
  PendingInternetRequest& request = it->second;
1560
1560
 
1561
+ // Check mesh connectivity before attempting retry
1562
+ // During bridge failover, connection may be temporarily lost
1563
+ if (!hasActiveMeshConnections()) {
1564
+ Log(logger::ERROR, "retryInternetRequest(): No active mesh connections for retry msgId=%u, rescheduling\n",
1565
+ messageId);
1566
+ scheduleInternetRetry(messageId);
1567
+ return;
1568
+ }
1569
+
1561
1570
  // Find gateway (may have changed)
1562
1571
  BridgeInfo* gateway = getPrimaryBridge();
1563
1572
  if (gateway == nullptr) {
@@ -228,7 +228,13 @@ class PackageHandler : public layout::Layout<T> {
228
228
  for (auto&& task : taskList) {
229
229
  if (task.use_count() == 1 && !task->isEnabled()) {
230
230
  task->set(aInterval, aIterations, aCallback, NULL, NULL);
231
- task->enable();
231
+ // Use enableDelayed() for delayed one-shot tasks to prevent immediate execution
232
+ // This ensures tasks with intervals execute after the delay, not immediately
233
+ if (aInterval > 0 && aIterations == TASK_ONCE) {
234
+ task->enableDelayed();
235
+ } else {
236
+ task->enable();
237
+ }
232
238
  return task;
233
239
  }
234
240
  }
@@ -236,7 +242,13 @@ class PackageHandler : public layout::Layout<T> {
236
242
  std::shared_ptr<Task> task =
237
243
  std::make_shared<Task>(aInterval, aIterations, aCallback);
238
244
  scheduler.addTask((*task));
239
- task->enable();
245
+ // Use enableDelayed() for delayed one-shot tasks to prevent immediate execution
246
+ // This ensures tasks with intervals execute after the delay, not immediately
247
+ if (aInterval > 0 && aIterations == TASK_ONCE) {
248
+ task->enableDelayed();
249
+ } else {
250
+ task->enable();
251
+ }
240
252
  taskList.push_front(task);
241
253
  return task;
242
254
  }
@@ -135,15 +135,9 @@ void connect(AsyncClient &client, IPAddress ip, uint16_t port, M &mesh,
135
135
  }, retryDelay);
136
136
 
137
137
  // Defer deletion of the failed AsyncClient to prevent heap corruption
138
- // Deleting from within the error callback can cause use-after-free issues
139
- // as the AsyncTCP library may still be referencing the object
140
- // Use TCP_CLIENT_CLEANUP_DELAY_MS to give AsyncTCP library time to complete
141
- // its internal cleanup before we delete the object
142
- // Note: client is captured by value (pointer copy) and we are the sole owner
143
- mesh.addTask([client]() {
144
- Log(CONNECTION, "tcp_err(): Cleaning up failed AsyncClient (retry path)\n");
145
- delete client;
146
- }, TCP_CLIENT_CLEANUP_DELAY_MS);
138
+ // Use the centralized deletion scheduler to ensure proper spacing between deletions
139
+ // This prevents concurrent cleanup operations in the AsyncTCP library
140
+ scheduleAsyncClientDeletion(mesh.mScheduler, client, "tcp_err(retry)");
147
141
 
148
142
  mesh.semaphoreGive();
149
143
  return;
@@ -156,15 +150,9 @@ void connect(AsyncClient &client, IPAddress ip, uint16_t port, M &mesh,
156
150
  TCP_CONNECT_MAX_RETRIES + 1, TCP_EXHAUSTION_RECONNECT_DELAY_MS);
157
151
 
158
152
  // Defer deletion of the failed AsyncClient to prevent heap corruption
159
- // Deleting from within the error callback can cause use-after-free issues
160
- // as the AsyncTCP library may still be referencing the object
161
- // Use TCP_CLIENT_CLEANUP_DELAY_MS to give AsyncTCP library time to complete
162
- // its internal cleanup before we delete the object
163
- // Note: client is captured by value (pointer copy) and we are the sole owner
164
- mesh.addTask([client]() {
165
- Log(CONNECTION, "tcp_err(): Cleaning up failed AsyncClient (exhaustion path)\n");
166
- delete client;
167
- }, TCP_CLIENT_CLEANUP_DELAY_MS);
153
+ // Use the centralized deletion scheduler to ensure proper spacing between deletions
154
+ // This prevents concurrent cleanup operations in the AsyncTCP library
155
+ scheduleAsyncClientDeletion(mesh.mScheduler, client, "tcp_err(exhaustion)");
168
156
  #endif
169
157
  // Defer callback execution to avoid crashes in error handler context
170
158
  // Execute callbacks after semaphore is released and error handler completes