@alteriom/painlessmesh 1.9.10 → 1.9.12

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,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
19
 
20
20
  - TBD
21
21
 
22
+ ## [1.9.12] - 2025-12-18
23
+
24
+ ### Fixed
25
+
26
+ - **Hard Reset During Bridge Operations - AsyncClient abort() Timing Issue** - Fixed ESP32/ESP8266 heap corruption crashes during TCP connection failures and bridge operations
27
+ - **Root Cause**: Calling `client->abort()` synchronously before scheduling deferred AsyncClient deletion (1000ms+ later) left the client in an inconsistent state where AsyncTCP's internal cleanup tried to access the aborted client
28
+ - **Symptom**: Device crashes with "CORRUPT HEAP: Bad head at 0x40838cdc. Expected 0xabba1234 got 0x4200822e" and "assert failed: multi_heap_free multi_heap_poisoning.c:279" during connection cleanup
29
+ - **Solution**: Removed synchronous `abort()` call from `~BufferedConnection()` destructor
30
+ - The existing `close()` and `close(true)` calls are sufficient for connection termination
31
+ - According to AsyncTCP best practices, `abort()` should only be called immediately before `delete`, not before a deferred deletion
32
+ - Eliminates the 1000ms window where AsyncTCP tries to clean up an aborted but not-yet-deleted client
33
+ - **Testing**: All test suites pass (1000+ assertions), including TCP retry, connection, and mesh connectivity tests
34
+ - **Documentation**: Added ISSUE_ABORT_TIMING_FIX.md with detailed AsyncTCP best practices analysis
35
+ - **Impact**: Completes the AsyncClient lifecycle management improvements, eliminating the last known heap corruption scenario in connection cleanup
36
+
37
+ ## [1.9.11] - 2025-12-18
38
+
39
+ ### Fixed
40
+
41
+ - **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
42
+ - **Root Cause**: Calling `addTask()` immediately after `stop()/initAsBridge()` cycle accessed unstable internal task scheduling structures before they were fully reinitialized in the new context
43
+ - **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
44
+ - **Solution**: Removed redundant task scheduling calls that were unsafe after stop/reinit
45
+ - Removed `addTask()` call in `attemptIsolatedBridgePromotion()` (line 1947)
46
+ - Removed `addTask()` call in `promoteToBridge()` (line 1822)
47
+ - Relied on existing `initBridgeStatusBroadcast()` infrastructure which safely handles announcements
48
+ - Used explicit `TSTRING` construction in callback invocations for string lifetime safety
49
+ - **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)
50
+ - **Testing**: All test suites pass (1000+ assertions), including bridge election and promotion tests
51
+ - **Documentation**: Added ISSUE_HARD_RESET_BRIDGE_PROMOTION_FIX.md with detailed analysis of both affected code paths
52
+ - **Impact**: Eliminates critical crash during bridge promotion, allows stable bridge failover operation in both isolated and competitive election scenarios
53
+
54
+ - **Bridge Failover & sendToInternet Retry Connectivity** - Fixed heap corruption and request timeouts when using bridge_failover with sendToInternet() during connection instability
55
+ - **Root Cause**: `retryInternetRequest()` did not check mesh connectivity before attempting retry, causing routing attempts through unreachable gateways during bridge disconnection
56
+ - **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()
57
+ - **Solution**: Added `hasActiveMeshConnections()` check at start of `retryInternetRequest()`
58
+ - Retry only proceeds if mesh connections are active
59
+ - If disconnected, reschedules retry instead of attempting to route
60
+ - Prevents routing to unreachable gateways during temporary disconnection
61
+ - Maintains existing retry logic and exponential backoff
62
+ - **Testing**: Added comprehensive test coverage (catch_sendtointernet_retry_no_mesh.cpp) with 31 assertions validating disconnected retry scenarios
63
+ - **Documentation**: Added BRIDGE_FAILOVER_RETRY_FIX.md with detailed analysis and usage notes
64
+ - **Impact**: Fixes critical stability issue during bridge failover, enabling reliable sendToInternet() usage in production deployments with unstable connections
65
+
66
+ - **Hard Reset During sendToInternet - Serialized AsyncClient Deletion** - Fixed ESP32/ESP8266 hard resets caused by heap corruption when multiple AsyncClient cleanup operations execute concurrently
67
+ - **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.
68
+ - **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.
69
+ - **Solution**: Implemented serialized deletion with 250ms spacing between consecutive AsyncClient deletions
70
+ - Added `TCP_CLIENT_DELETION_SPACING_MS` constant (250ms) to ensure deletions don't overlap
71
+ - Added global `lastScheduledDeletionTime` tracker to coordinate deletion timing
72
+ - Implemented `scheduleAsyncClientDeletion()` function that calculates proper spacing
73
+ - Updated `BufferedConnection` destructor and tcp.hpp error handlers to use centralized scheduler
74
+ - Ensures each AsyncClient deletion completes before the next one starts
75
+ - Handles millis() rollover and multiple concurrent deletion requests
76
+ - **Performance Impact**:
77
+ - Single deletion: No change (1000ms)
78
+ - Multiple concurrent deletions: Spaced by 250ms each (total spread <1 second for typical scenarios)
79
+ - High-churn scenario (10 failures): Spread over ~3 seconds (still acceptable)
80
+ - **Testing**: All test suites pass (1000+ assertions), including new deletion spacing tests (47 assertions in tcp_retry)
81
+ - **Documentation**: Added ISSUE_HARD_RESET_SENDTOINTERNET_SERIALIZED_DELETION_FIX.md with detailed analysis
82
+ - **Impact**: Fixes critical stability issue in production deployments with high connection churn, particularly affecting sendToInternet and bridge failover scenarios
83
+
22
84
  ## [1.9.10] - 2025-12-15
23
85
 
24
86
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div align="center">
6
6
 
7
- **Version 1.9.10** - Latest release with critical bug fixes for TCP retry and bridge failover
7
+ **Version 1.9.12** - Latest release with AsyncClient abort() timing fix for heap corruption prevention
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)
@@ -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:
@@ -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);
@@ -146,6 +146,23 @@ mesh.sendToInternet("https://api.callmebot.com/...", "", callback);
146
146
  3. Check HTTP status code in callback (200 = success)
147
147
  4. URL-encode special characters in the message
148
148
 
149
+ ### Understanding HTTP Status Codes
150
+
151
+ The callback provides `httpStatus` to indicate the result:
152
+
153
+ **SUCCESS (success = true):**
154
+ - `200 OK` - Standard success (most common for WhatsApp API)
155
+ - `201 Created` - Resource successfully created
156
+ - `202 Accepted` - Request accepted for processing
157
+ - `204 No Content` - Successful with no response body
158
+
159
+ **FAILURE (success = false):**
160
+ - `203 Non-Authoritative Information` - **Cached/proxied response, NOT actual delivery**
161
+ - `4xx` - Client error (bad request, unauthorized, not found, etc.)
162
+ - `5xx` - Server error (service unavailable, gateway timeout, etc.)
163
+
164
+ ⚠️ **Important:** HTTP 203 is treated as **FAILURE** because it indicates the response came from a cache or proxy, not from the actual WhatsApp API server. If you see `HTTP Status: 203`, the message was **NOT delivered**.
165
+
149
166
  ## Files
150
167
 
151
168
  - `sendToInternet.ino` - Main example sketch
@@ -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/
@@ -184,6 +184,13 @@ void sendAlertToWhatsApp(String message) {
184
184
 
185
185
  // Use sendToInternet() to route the request through a gateway
186
186
  // The callback will be invoked when we get a response (or timeout)
187
+ //
188
+ // SUCCESS CODES: Only specific HTTP codes indicate genuine delivery:
189
+ // - 200 OK: Standard success (most common for WhatsApp API)
190
+ // - 201 Created, 202 Accepted, 204 No Content
191
+ //
192
+ // FAILURE: HTTP 203 (Non-Authoritative) is treated as FAILURE because
193
+ // it indicates a cached/proxied response, not actual delivery to WhatsApp.
187
194
  uint32_t msgId = mesh.sendToInternet(
188
195
  url,
189
196
  "", // No payload needed for GET request - params are in URL
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.10",
9
+ "version": "1.9.12",
10
10
  "frameworks": [
11
11
  "arduino"
12
12
  ],
@@ -1,5 +1,5 @@
1
1
  name=Alteriom PainlessMesh
2
- version=1.9.10
2
+ version=1.9.12
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.10",
3
+ "version": "1.9.12",
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.10"
32
+ #define ALTERIOM_PAINLESS_MESH_VERSION "1.9.12"
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 10
35
+ #define ALTERIOM_PAINLESS_MESH_VERSION_PATCH 12
36
36
 
37
37
  /**
38
38
  * @brief Library description and usage information
@@ -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
  }
@@ -2164,10 +2158,37 @@ class Mesh : public painlessmesh::Mesh<Connection> {
2164
2158
  }
2165
2159
 
2166
2160
  if (httpCode > 0) {
2167
- // Only 2xx status codes are treated as success
2161
+ // Only specific 2xx status codes indicate genuine success
2162
+ // 200 OK: Standard successful response
2163
+ // 201 Created: Resource successfully created
2164
+ // 202 Accepted: Request accepted for processing
2165
+ // 204 No Content: Successful with no response body
2166
+ //
2167
+ // Other 2xx codes like 203 (Non-Authoritative Information) often
2168
+ // indicate cached/proxied responses that may not represent actual
2169
+ // delivery to the destination service (e.g., WhatsApp API).
2170
+ //
2168
2171
  // 3xx redirects are not automatically followed
2169
- success = (httpCode >= 200 && httpCode < 300);
2170
- Log(COMMUNICATION, "HTTP request completed: code=%d\n", httpCode);
2172
+ success = (httpCode == 200 || httpCode == 201 ||
2173
+ httpCode == 202 || httpCode == 204);
2174
+
2175
+ if (success) {
2176
+ Log(COMMUNICATION, "HTTP request completed: code=%d\n", httpCode);
2177
+ } else if (httpCode >= 200 && httpCode < 300) {
2178
+ // Other 2xx codes - ambiguous success
2179
+ char errorBuf[128];
2180
+ snprintf(errorBuf, sizeof(errorBuf),
2181
+ "Ambiguous response - HTTP %d may indicate cached/proxied response, not actual delivery",
2182
+ httpCode);
2183
+ error = TSTRING(errorBuf);
2184
+ Log(ERROR, "HTTP request ambiguous: code=%d (treated as failure)\n", httpCode);
2185
+ } else {
2186
+ // 1xx, 3xx, 4xx, 5xx
2187
+ char errorBuf[32];
2188
+ snprintf(errorBuf, sizeof(errorBuf), "HTTP %d", httpCode);
2189
+ error = TSTRING(errorBuf);
2190
+ Log(ERROR, "HTTP request failed: code=%d\n", httpCode);
2191
+ }
2171
2192
  } else {
2172
2193
  error = http.errorToString(httpCode);
2173
2194
  Log(ERROR, "HTTP request failed: %s\n", error.c_str());
@@ -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.10
9
- * @date 2025-12-15
8
+ * @version 1.9.12
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,126 @@ 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 and execution
32
+ // This ensures deletions are spaced out even when multiple deletion requests arrive simultaneously
33
+ // The timestamp is updated both when a deletion is SCHEDULED and when it EXECUTES, providing
34
+ // double protection against concurrent cleanup operations even with scheduler jitter
35
+ //
36
+ // THREAD SAFETY: No synchronization needed because:
37
+ // - ESP32/ESP8266 Arduino framework is single-threaded by design
38
+ // - TaskScheduler executes callbacks sequentially in the main loop
39
+ // - The scheduler never runs tasks concurrently within the same mesh instance
40
+ // - All mesh operations (including deletion callbacks) execute in the same thread
41
+ // - Even when multiple tasks are ready, they execute one-at-a-time via scheduler->execute()
42
+ static uint32_t lastScheduledDeletionTime = 0; // Timestamp of last deletion scheduled/executed (milliseconds)
21
43
 
22
44
  // Shared buffer for reading/writing to the buffer
23
45
  static painlessmesh::buffer::temp_buffer_t shared_buffer;
24
46
 
47
+ /**
48
+ * Schedule deletion of an AsyncClient with proper spacing to prevent concurrent cleanups
49
+ *
50
+ * This function ensures that AsyncClient deletions are spaced out in time to prevent
51
+ * the AsyncTCP library's internal cleanup routines from interfering with each other.
52
+ *
53
+ * When multiple AsyncClient objects need to be deleted (e.g., during high connection churn
54
+ * or sendToInternet scenarios), scheduling them all with the same delay can cause them to
55
+ * execute concurrently, leading to heap corruption.
56
+ *
57
+ * This function maintains a global timestamp of when the last deletion was scheduled and
58
+ * calculates an appropriate delay for the new deletion to ensure adequate spacing.
59
+ *
60
+ * @param scheduler The task scheduler to use for scheduling the deletion
61
+ * @param client The AsyncClient pointer to delete
62
+ * @param logPrefix Prefix for the log message (e.g., "~BufferedConnection" or "tcp_err")
63
+ */
64
+ inline void scheduleAsyncClientDeletion(Scheduler* scheduler, AsyncClient* client, const char* logPrefix) {
65
+ using namespace logger;
66
+
67
+ if (!scheduler) {
68
+ // Fallback: If scheduler not available, delete immediately (risky)
69
+ Log(CONNECTION, "%s: No scheduler available, deleting AsyncClient immediately (risky)\n", logPrefix);
70
+ delete client;
71
+ return;
72
+ }
73
+
74
+ // Get current time in milliseconds
75
+ uint32_t currentTime = millis();
76
+
77
+ // Calculate the earliest time this deletion should execute
78
+ // Base delay: TCP_CLIENT_CLEANUP_DELAY_MS (1000ms)
79
+ uint32_t baseDelay = TCP_CLIENT_CLEANUP_DELAY_MS;
80
+
81
+ // Calculate when this deletion should execute relative to the last scheduled deletion
82
+ // If the last deletion was scheduled recently, we need to add additional spacing
83
+ uint32_t targetDeletionTime = currentTime + baseDelay;
84
+
85
+ // If there's a recent deletion scheduled, ensure we space out from it
86
+ if (lastScheduledDeletionTime > 0) {
87
+ // Calculate when the next deletion slot is available
88
+ uint32_t nextAvailableSlot = lastScheduledDeletionTime + TCP_CLIENT_DELETION_SPACING_MS;
89
+
90
+ // If our target deletion time is before the next available slot, push it out
91
+ // Handle millis() rollover: Use signed arithmetic to detect if nextAvailableSlot is "in the future"
92
+ // relative to targetDeletionTime. This works because:
93
+ // - If difference is positive and < 2^31: nextAvailableSlot is ahead, we need to wait
94
+ // - If difference is negative or > 2^31: nextAvailableSlot is in the past (or very far future after rollover), use targetDeletionTime
95
+ int32_t timeUntilSlot = (int32_t)(nextAvailableSlot - targetDeletionTime);
96
+ if (timeUntilSlot > 0 && timeUntilSlot < (int32_t)(1U << 30)) {
97
+ // nextAvailableSlot is reasonably soon in the future (< ~12 days), space from it
98
+ targetDeletionTime = nextAvailableSlot;
99
+ }
100
+ // else: lastScheduledDeletionTime is too old (> baseDelay+spacing), or rollover occurred
101
+ // In this case, just use targetDeletionTime (currentTime + baseDelay) and reset spacing
102
+ }
103
+
104
+ // Calculate the actual delay from now
105
+ uint32_t actualDelay = targetDeletionTime - currentTime;
106
+
107
+ // Update the last scheduled deletion time
108
+ // IMPORTANT: We update this when scheduling, which provides a minimum guaranteed spacing
109
+ // between scheduled deletions. This ensures even with scheduler jitter, deletions won't
110
+ // be scheduled too close together.
111
+ lastScheduledDeletionTime = targetDeletionTime;
112
+
113
+ Log(CONNECTION, "%s: Scheduling AsyncClient deletion in %u ms (spaced from previous deletions)\n",
114
+ logPrefix, actualDelay);
115
+
116
+ // Schedule the deletion task
117
+ // Note: Task object is intentionally leaked to keep implementation simple
118
+ // This is acceptable because:
119
+ // 1. Connections are long-lived, destructor calls are infrequent
120
+ // 2. Task object is small (~32-64 bytes) vs preventing critical heap corruption
121
+ // 3. In typical deployments, memory impact is negligible (few KB over months)
122
+ // 4. Alternative cleanup patterns would add significant complexity
123
+ Task* cleanupTask = new Task(actualDelay * TASK_MILLISECOND, TASK_ONCE, [client, logPrefix]() {
124
+ using namespace logger;
125
+ Log(CONNECTION, "%s: Deferred cleanup of AsyncClient executing now\n", logPrefix);
126
+
127
+ // Update the last deletion time when the deletion actually executes
128
+ // This ensures subsequent deletions are spaced from the actual execution time,
129
+ // not just the scheduled time, preventing concurrent cleanup operations
130
+ lastScheduledDeletionTime = millis();
131
+
132
+ delete client;
133
+ });
134
+
135
+ scheduler->addTask(*cleanupTask);
136
+ cleanupTask->enableDelayed();
137
+ }
138
+
25
139
  /**
26
140
  * Class that performs buffered read and write to the tcp connection
27
141
  * (asyncclient)
@@ -51,38 +165,17 @@ class BufferedConnection
51
165
  if (!client->freeable()) {
52
166
  client->close(true);
53
167
  }
54
- client->abort();
168
+ // Note: client->abort() removed - calling it before deferred deletion
169
+ // can leave the client in an inconsistent state where AsyncTCP is still
170
+ // trying to clean up the aborted connection. The close() and close(true)
171
+ // calls above are sufficient for connection termination.
172
+ // See: AsyncTCP best practices - abort() should only be called immediately
173
+ // before delete, not before a deferred deletion.
55
174
 
56
175
  // 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
176
+ // Use the centralized deletion scheduler to ensure proper spacing between deletions
59
177
  // 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
- }
178
+ scheduleAsyncClientDeletion(mScheduler, client, "~BufferedConnection");
86
179
  }
87
180
 
88
181
  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) {
@@ -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