Skip to content

feat(L2CAP): add disconnect API and harden CoC send/error handling#396

Open
mickeyl wants to merge 6 commits intoh2zero:masterfrom
mickeyl:l2cap-updates
Open

feat(L2CAP): add disconnect API and harden CoC send/error handling#396
mickeyl wants to merge 6 commits intoh2zero:masterfrom
mickeyl:l2cap-updates

Conversation

@mickeyl
Copy link
Contributor

@mickeyl mickeyl commented Mar 2, 2026

This PR bundles the recent L2CAP CoC work from l2cap-updates on top of release 2.3.4.

What is in here

  • Add NimBLEL2CAPChannel::disconnect().
  • Add NimBLEL2CAPChannel::getConnHandle().
  • Fix CoC TX mbuf ownership handling in writeFragment():
    • treat BLE_HS_ENOMEM / BLE_HS_EAGAIN as consumed buffer,
    • only free local tx mbuf on BLE_HS_EBUSY.
  • Refresh L2CAP client/server examples for stress testing and runtime stats.
  • Pin example dependency mickeyl/esp-hpl to tag 1.1.0.
  • Clean trailing whitespace in updated example sources.

Verification

  • L2CAP_Client builds.
  • L2CAP_Server builds.

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): clear rx->sdus[current_sdu_idx] = NULL before 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.

mickeyl and others added 6 commits January 13, 2026 10:47
…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.
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.
Copilot AI review requested due to automatic review settings March 2, 2026 10:01
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and NimBLEL2CAPChannel::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-hpl to 1.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 {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
uint16_t NimBLEL2CAPChannel::getConnHandle() const {
uint16_t NimBLEL2CAPChannel::getConnHandle() const {
if (!this->channel) {
return BLE_HS_CONN_HANDLE_NONE;
}

Copilot uses AI. Check for mistakes.
if (currentHeap < lastHeap) {
heapDecreaseCount++;
if (heapDecreaseCount >= HEAP_LEAK_THRESHOLD) {
printf("\n⚠️ WARNING: POSSIBLE MEMORY LEAK DETECTED! ⚠️\n");
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning printf includes non-ASCII characters (⚠️). Depending on toolchain/source encoding and terminal, this can cause build/garbled output issues in embedded environments; consider using plain ASCII text for the warning banner.

Suggested change
printf("\n⚠️ WARNING: POSSIBLE MEMORY LEAK DETECTED! ⚠️\n");
printf("\n*** WARNING: POSSIBLE MEMORY LEAK DETECTED! ***\n");

Copilot uses AI. Check for mistakes.
Comment on lines +160 to +166
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;
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 14 to +18

size_t bytesSent = 0;
size_t bytesReceived = 0;
size_t currentPayloadSize = INITIAL_PAYLOAD_SIZE;
uint32_t blocksSent = 0;
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +87
double bytesPerSecond = bytesSent / elapsedSeconds;
double kbPerSecond = bytesPerSecond / 1024.0;
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}

Copilot uses AI. Check for mistakes.
Comment on lines 15 to +21
size_t bytesSent = 0;
size_t bytesReceived = 0;
size_t currentPayloadSize = INITIAL_PAYLOAD_SIZE;
uint32_t blocksSent = 0;
uint64_t startTime = 0;

// Heap monitoring
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +35
uint8_t expectedSequenceNumber = 0;
size_t sequenceErrors = 0;
size_t frameErrors = 0;
uint64_t startTime = 0;
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +71
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
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +89
// Remove processed frame from buffer
buffer.erase(buffer.begin(), buffer.begin() + frameSize);

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +186
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);
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning printf includes non-ASCII characters (⚠️). Depending on toolchain/source encoding and terminal, this can cause build/garbled output issues in embedded environments; consider using plain ASCII text for the warning banner.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants