Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/tests/l2cap-build/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
build*/
sdkconfig
sdkconfig.old
managed_components/
dependencies.lock
6 changes: 6 additions & 0 deletions .github/tests/l2cap-build/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
cmake_minimum_required(VERSION 3.16)
get_filename_component(NIMBLE_CPP_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE)
set(EXTRA_COMPONENT_DIRS "${NIMBLE_CPP_ROOT}")
set(COMPONENTS main)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(nimble_l2cap_build)
1 change: 1 addition & 0 deletions .github/tests/l2cap-build/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
idf_component_register(SRCS "main.cpp" REQUIRES esp-nimble-cpp)
49 changes: 49 additions & 0 deletions .github/tests/l2cap-build/main/main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#include "NimBLEDevice.h"
#include "NimBLEL2CAPServer.h"
#include "NimBLEL2CAPChannel.h"
#include "freertos/queue.h"
#include <atomic>
#include <cassert>

// Compile/link probe and minimal worker-driven echo pattern. The service lives
// for the process lifetime; no callback deletes its channel or blocks for TX.
struct EchoJob {
NimBLEL2CAPChannel* channel;
uint32_t generation;
std::vector<uint8_t> data;
};
static QueueHandle_t jobs;
static std::atomic<uint32_t> generation{0};

class EchoCallbacks : public NimBLEL2CAPChannelCallbacks {
void onConnect(NimBLEL2CAPChannel*, uint16_t) override { ++generation; }
void onDisconnect(NimBLEL2CAPChannel*) override { ++generation; }
void onRead(NimBLEL2CAPChannel* channel, std::vector<uint8_t>& bytes) override {
auto* job = new EchoJob{channel, generation.load(), bytes};
if (xQueueSend(jobs, &job, 0) != pdTRUE) {
delete job;
channel->disconnect();
}
}
};

static void echoWorker(void*) {
EchoJob* job;
while (true) {
if (xQueueReceive(jobs, &job, portMAX_DELAY) != pdTRUE) continue;
if (job->generation == generation.load() && job->channel->isConnected()) {
job->channel->write(job->data);
}
delete job;
}
}

extern "C" void app_main() {
jobs = xQueueCreate(4, sizeof(EchoJob*));
assert(jobs);
const auto created = xTaskCreate(echoWorker, "coc-echo", 4096, nullptr, 5, nullptr);
assert(created == pdPASS);
NimBLEDevice::init("coc-build-probe");
auto* channel = NimBLEDevice::createL2CAPServer()->createService(129, 4096, new EchoCallbacks);
(void)channel->isIdle();
}
4 changes: 4 additions & 0 deletions .github/tests/l2cap-build/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=1
CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=n
1 change: 1 addition & 0 deletions .github/tests/l2cap-build/sdkconfig.deferred
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=y
42 changes: 42 additions & 0 deletions .github/workflows/l2cap-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: L2CAP CoC build
on:
workflow_dispatch:
pull_request:
push:
branches: [master]
permissions:
contents: read

jobs:
coc:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
idf: [v5.4.3, v5.5.1]
callbacks: [inline, deferred]
container: espressif/idf:${{ matrix.idf }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
path: esp-nimble-cpp
- name: Compile and link CoC implementation
env:
IDF_TARGET: esp32s3
CALLBACKS: ${{ matrix.callbacks }}
shell: bash
run: |
. "${IDF_PATH}/export.sh"
cd esp-nimble-cpp/.github/tests/l2cap-build
defaults="sdkconfig.defaults"
if [ "$CALLBACKS" = deferred ]; then defaults="$defaults;sdkconfig.deferred"; fi
idf.py -D "SDKCONFIG_DEFAULTS=$defaults" build
python - "$CALLBACKS" <<'PYTHON'
import sys
from pathlib import Path
config = Path("sdkconfig").read_text().splitlines()
assert "CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=1" in config
enabled = "CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=y" in config
assert enabled == (sys.argv[1] == "deferred")
PYTHON
22 changes: 22 additions & 0 deletions .github/workflows/l2cap-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: L2CAP native tests
on:
workflow_dispatch:
pull_request:
push:
branches: [master]
permissions:
contents: read

jobs:
native:
runs-on: ubuntu-latest
strategy:
matrix:
cxx_flags: ["", "-fsanitize=address,undefined -fno-omit-frame-pointer"]
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- run: cmake -S tests/l2cap -B build/l2cap-tests -DCMAKE_CXX_FLAGS="${{ matrix.cxx_flags }}"
- run: cmake --build build/l2cap-tests
- run: ctest --test-dir build/l2cap-tests --output-on-failure
63 changes: 62 additions & 1 deletion Kconfig
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,65 @@ config NIMBLE_CPP_DEBUG_ASSERT_ENABLED
Enabling this option will add debug asserts to the NimBLE CPP library.
This will use approximately 1kB of flash memory.

endmenu
config NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT
int "L2CAP SDU buffer count."
range 2 32
default 5
help
Number of full-SDU mbufs allocated per NimBLEL2CAPChannel instance for
local CoC send/receive work. Each buffer is sized to the channel MTU, so
increasing this directly increases per-channel heap consumption.

Higher values improve tolerance for bursty traffic, overlapping send and
receive work, deferred read callback lag, and other short periods where
the channel needs more than one full SDU buffer ready at once.

Lower values reduce memory footprint, but make the channel more fragile
under load. If this value is too small for the negotiated MTU and the way
the application consumes callbacks, local allocations may fail and large
SDUs can start timing out or disconnecting even though CoC MTU negotiation
itself succeeded.

The default value of 5 preserves the currently validated behavior. Reduce
it only when memory is tight and the application traffic pattern is known
to be light; increase it if callbacks may lag or if multiple large SDUs
must be in flight locally.

config NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS
bool "Defer L2CAP read callbacks to a worker task."
default "n"
help
When enabled, NimBLEL2CAPChannel rearms receive first on the NimBLE host task
and then dispatches onConnect() and onRead() callbacks on a shared worker task.
Disconnect callbacks always run on that worker, outside the NimBLE host lock.
This reduces the risk of doing heavy application work directly on the NimBLE host path.

if NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS

config NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE
int "L2CAP callback worker stack size."
range 2048 32768
default 4096
help
Stack size in bytes for the shared worker task used to invoke deferred
L2CAP read callbacks.

config NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY
int "L2CAP callback worker task priority."
range 1 24
default 5
help
FreeRTOS priority for the shared worker task used to invoke deferred
L2CAP read callbacks.

config NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH
int "L2CAP callback queue length."
range 4 128
default 16
help
Number of queued deferred L2CAP read callbacks. Overflow aborts the
connection; additional reserved slots guarantee lifecycle delivery.

endif

endmenu
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.DEFAULT_GOAL := help
SANITIZER ?= none
L2CAP_BUILD ?= build/l2cap-$(SANITIZER)
SAN_FLAGS_none :=
SAN_FLAGS_address := -fsanitize=address,undefined -fno-omit-frame-pointer
SAN_FLAGS_thread := -fsanitize=thread -fno-omit-frame-pointer

.PHONY: help test-l2cap
help: ## Show development targets.
@awk 'BEGIN { print "Usage: make <target> [SANITIZER=none|address|thread] [L2CAP_BUILD=path]\n" } /^[a-zA-Z0-9_.-]+:.*##/ { split($$0, a, ":.*## "); printf " %-18s %s\n", a[1], a[2] }' $(MAKEFILE_LIST)

test-l2cap: ## Build and run the L2CAP concurrency and error tests.
@case "$(SANITIZER)" in none|address|thread) ;; *) echo "Unknown SANITIZER: use none, address or thread" >&2; exit 2;; esac
cmake -S tests/l2cap -B "$(L2CAP_BUILD)" -DCMAKE_CXX_FLAGS="$(SAN_FLAGS_$(SANITIZER))"
cmake --build "$(L2CAP_BUILD)"
ctest --test-dir "$(L2CAP_BUILD)" --output-on-failure
10 changes: 10 additions & 0 deletions src/NimBLEDevice.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,17 @@ bool NimBLEDevice::deinit(bool clearAll) {
int rc = 0;
if (m_initialized) {
rc = nimble_port_stop();
if (rc != 0) { return false; }
if (rc == 0) {
# if MYNEWT_VAL(BLE_ROLE_PERIPHERAL) && MYNEWT_VAL(BLE_L2CAP_COC_MAX_NUM)
// The stopped host cannot enqueue new channel jobs. Destroy CoC
// events while the NPL function table and event pool still exist;
// nimble_port_deinit() releases both on ESP-IDF.
if (clearAll && m_pL2CAPServer != nullptr) {
delete m_pL2CAPServer;
m_pL2CAPServer = nullptr;
}
# endif
# if MYNEWT_VAL(BLE_ROLE_OBSERVER)
if (NimBLEDevice::m_pScan != nullptr) {
NimBLEDevice::m_pScan->onHostDeinit();
Expand Down
Loading
Loading