feat(L2CAP): add disconnect API and harden CoC send/error handling#396
feat(L2CAP): add disconnect API and harden CoC send/error handling#396mickeyl wants to merge 6 commits intoh2zero:masterfrom
Conversation
…nitoring
This commit improves the L2CAP testing framework to help debug (suspected)
L2CAP issues in the NimBLE stack. The examples implement a client-server pair
that can stress test L2CAP connections with progressive payload sizes.
Client (L2CAP_Client):
- Connects to devices advertising with name "l2cap"
- Uses PSM 192 for L2CAP connection
- Implements framed protocol: [seqno:8bit][length:16bit BE][payload]
- Starts with 64-byte payloads, doubles every 50 blocks up to 1024 bytes
- Sends data continuously without delays for maximum throughput
- Includes real-time bandwidth and performance monitoring
Server (L2CAP_Server):
- Advertises as "l2cap" to match client scanning
- Listens on PSM 192
- Parses framed protocol with proper buffering for fragmented frames
- Validates sequence numbers and reports errors
- Tracks frames per second, total bytes, and bandwidth
Both examples include:
- Heap memory monitoring with leak detection
- Warns after 10 consecutive heap decreases
- Status updates every second showing:
- Bandwidth in KB/s and Mbps
- Current/minimum heap memory
- Memory used since start
- Frame/block statistics
Testing results:
- Stable operation at ~84 KB/s (0.69 Mbps) with 1024-byte payloads
- Identified crash when attempting 2048-byte payloads (memory allocation issue)
- No memory leaks detected during extended operation
This framework enables systematic debugging of L2CAP implementation issues
by providing detailed metrics and stress testing capabilities.
…l for minimal-impact-debug-logs
Handle ENOMEM/EAGAIN from `ble_l2cap_send` as “mbuf consumed” and only free tx buffers on EBUSY; prevents double-free pool corruption during CoC stress. ESP-IDF NimBLE host change needed separately: In components/bt/host/nimble/nimble/nimble/host/src/ble_l2cap_coc.c, when an SDU completes (RX path), clear rx->sdus[current_sdu_idx] = NULL before advancing the index so cleanup won’t free a buffer already handed to the application.
There was a problem hiding this comment.
Pull request overview
Adds new L2CAP CoC channel control APIs and hardens CoC TX buffer ownership semantics, while updating the L2CAP client/server examples to support stress testing and runtime statistics.
Changes:
- Add
NimBLEL2CAPChannel::disconnect()andNimBLEL2CAPChannel::getConnHandle(). - Adjust
writeFragment()retry/error handling to reflect NimBLE mbuf ownership on specific return codes. - Refresh L2CAP client/server examples (framing, stats, heap monitoring) and pin
mickeyl/esp-hplto1.1.0.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/NimBLEL2CAPChannel.h | Declares new channel APIs (disconnect, getConnHandle). |
| src/NimBLEL2CAPChannel.cpp | Implements new APIs and updates CoC send retry / mbuf ownership handling. |
| examples/L2CAP/L2CAP_Server/main/main.cpp | Updates server example for framed RX parsing, stats, heap monitoring, and advertising behavior. |
| examples/L2CAP/L2CAP_Server/main/idf_component.yml | Adds/pins mickeyl/esp-hpl dependency for the server example. |
| examples/L2CAP/L2CAP_Client/main/main.cpp | Updates client example for framed TX, status task, heap monitoring, and scan-by-name discovery. |
| examples/L2CAP/L2CAP_Client/main/idf_component.yml | Adds/pins mickeyl/esp-hpl dependency for the client example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return true; | ||
| } | ||
|
|
||
| uint16_t NimBLEL2CAPChannel::getConnHandle() const { |
There was a problem hiding this comment.
getConnHandle() unconditionally calls ble_l2cap_get_conn_handle(this->channel), but the header contract says it should return BLE_HS_CONN_HANDLE_NONE when not connected. As written, calling this while channel == nullptr can dereference/assume an invalid channel and crash depending on NimBLE implementation; return BLE_HS_CONN_HANDLE_NONE early when !this->channel.
| uint16_t NimBLEL2CAPChannel::getConnHandle() const { | |
| uint16_t NimBLEL2CAPChannel::getConnHandle() const { | |
| if (!this->channel) { | |
| return BLE_HS_CONN_HANDLE_NONE; | |
| } |
| if (currentHeap < lastHeap) { | ||
| heapDecreaseCount++; | ||
| if (heapDecreaseCount >= HEAP_LEAK_THRESHOLD) { | ||
| printf("\n⚠️ WARNING: POSSIBLE MEMORY LEAK DETECTED! ⚠️\n"); |
There was a problem hiding this comment.
The warning printf includes non-ASCII characters (
| printf("\n⚠️ WARNING: POSSIBLE MEMORY LEAK DETECTED! ⚠️\n"); | |
| printf("\n*** WARNING: POSSIBLE MEMORY LEAK DETECTED! ***\n"); |
| if (l2capChannelCallbacks->connected && l2capChannelCallbacks->totalBytesReceived > 0) { | ||
| uint64_t currentTime = esp_timer_get_time(); | ||
| double elapsedSeconds = (currentTime - l2capChannelCallbacks->startTime) / 1000000.0; | ||
|
|
||
| if (elapsedSeconds > 0) { | ||
| double bytesPerSecond = l2capChannelCallbacks->totalBytesReceived / elapsedSeconds; | ||
| double framesPerSecond = l2capChannelCallbacks->totalFramesReceived / elapsedSeconds; |
There was a problem hiding this comment.
L2CAPChannelCallbacks fields like totalBytesReceived, totalFramesReceived, and especially startTime are written from NimBLE callback context (onRead / onConnect) while the status loop reads them concurrently in app_main. This introduces C++ data races (and torn 64-bit reads) that can yield undefined behavior; guard shared state with atomics or a mutex/critical section, or snapshot the stats via a queue/message to the app task.
|
|
||
| size_t bytesSent = 0; | ||
| size_t bytesReceived = 0; | ||
| size_t currentPayloadSize = INITIAL_PAYLOAD_SIZE; | ||
| uint32_t blocksSent = 0; |
There was a problem hiding this comment.
bytesReceived is declared but never read/updated anywhere in this example now. If examples are built with warnings-as-errors, this can trigger an unused-variable warning; remove it or wire it into the receive path.
| double bytesPerSecond = bytesSent / elapsedSeconds; | ||
| double kbPerSecond = bytesPerSecond / 1024.0; |
There was a problem hiding this comment.
statusTask computes bytesPerSecond = bytesSent / elapsedSeconds without guarding elapsedSeconds > 0. If currentTime == startTime (or a torn 64-bit read yields 0), this can divide by zero and print inf/NaN; add an elapsedSeconds > 0 check like the server example does before doing the division.
| double bytesPerSecond = bytesSent / elapsedSeconds; | |
| double kbPerSecond = bytesPerSecond / 1024.0; | |
| double bytesPerSecond = 0.0; | |
| double kbPerSecond = 0.0; | |
| if (elapsedSeconds > 0.0) { | |
| bytesPerSecond = bytesSent / elapsedSeconds; | |
| kbPerSecond = bytesPerSecond / 1024.0; | |
| } |
| size_t bytesSent = 0; | ||
| size_t bytesReceived = 0; | ||
| size_t currentPayloadSize = INITIAL_PAYLOAD_SIZE; | ||
| uint32_t blocksSent = 0; | ||
| uint64_t startTime = 0; | ||
|
|
||
| // Heap monitoring |
There was a problem hiding this comment.
This example updates bytesSent, blocksSent, currentPayloadSize, and startTime from connectTask while statusTask reads them concurrently. On ESP32, uint64_t reads/writes are not atomic and C++ data races are undefined behavior; use atomics or a mutex/critical section (or copy stats into a struct guarded by a lock) for cross-task sharing.
| uint8_t expectedSequenceNumber = 0; | ||
| size_t sequenceErrors = 0; | ||
| size_t frameErrors = 0; | ||
| uint64_t startTime = 0; |
There was a problem hiding this comment.
frameErrors is tracked and printed but never incremented anywhere in the new framing parser, so the statistic will always stay at 0. Either increment it on invalid framing conditions (e.g., impossible length) or remove the counter to avoid misleading output.
| uint8_t seqno = buffer[0]; | ||
| uint16_t payloadLen = (buffer[1] << 8) | buffer[2]; // Big-endian | ||
|
|
||
| size_t frameSize = 3 + payloadLen; | ||
|
|
||
| // Check if we have complete frame | ||
| if (buffer.size() < frameSize) { | ||
| break; // Wait for more data | ||
| } |
There was a problem hiding this comment.
The frame parser trusts the 16-bit payloadLen from the stream. If it is corrupted/maliciously large, frameSize becomes huge and the code will break waiting for more bytes, causing buffer to grow without bound (memory exhaustion). Add a sanity check (e.g., payloadLen <= L2CAP_MTU - 3 or another explicit max) and handle invalid lengths by counting a frame error / resetting the buffer / disconnecting.
| // Remove processed frame from buffer | ||
| buffer.erase(buffer.begin(), buffer.begin() + frameSize); | ||
|
|
There was a problem hiding this comment.
buffer.erase(buffer.begin(), buffer.begin() + frameSize) inside the loop is O(n) per frame due to memmoves, which can become a bottleneck during high-throughput stress tests. Consider keeping an index/offset into the vector and occasionally compacting, or using a ring buffer/deque to avoid repeated shifting.
| if (heapDecreaseCount >= HEAP_LEAK_THRESHOLD) { | ||
| printf("\n⚠️ WARNING: POSSIBLE MEMORY LEAK DETECTED! ⚠️\n"); | ||
| printf("Heap has decreased %zu times in a row\n", heapDecreaseCount); | ||
| printf("Initial heap: %zu, Current heap: %zu, Lost: %zu bytes\n", | ||
| initialHeap, currentHeap, initialHeap - currentHeap); | ||
| } |
There was a problem hiding this comment.
The warning printf includes non-ASCII characters (
This PR bundles the recent L2CAP CoC work from
l2cap-updateson top of release2.3.4.What is in here
NimBLEL2CAPChannel::disconnect().NimBLEL2CAPChannel::getConnHandle().writeFragment():BLE_HS_ENOMEM/BLE_HS_EAGAINas consumed buffer,BLE_HS_EBUSY.mickeyl/esp-hplto tag1.1.0.Verification
L2CAP_Clientbuilds.L2CAP_Serverbuilds.Important caveat
For heavy CoC stress scenarios, some ESP-IDF NimBLE host revisions may still require a host-side fix in
ble_l2cap_coc.c(RX SDU ring handling): clearrx->sdus[current_sdu_idx] = NULLbefore advancing the index after handing a completed SDU to the app.This PR keeps esp-nimble-cpp-side ownership handling correct; host-side behavior depends on the NimBLE revision shipped with ESP-IDF.