diff --git a/.github/tests/l2cap-build/.gitignore b/.github/tests/l2cap-build/.gitignore new file mode 100644 index 00000000..6006c87e --- /dev/null +++ b/.github/tests/l2cap-build/.gitignore @@ -0,0 +1,5 @@ +build*/ +sdkconfig +sdkconfig.old +managed_components/ +dependencies.lock diff --git a/.github/tests/l2cap-build/CMakeLists.txt b/.github/tests/l2cap-build/CMakeLists.txt new file mode 100644 index 00000000..ef77e2d8 --- /dev/null +++ b/.github/tests/l2cap-build/CMakeLists.txt @@ -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) diff --git a/.github/tests/l2cap-build/main/CMakeLists.txt b/.github/tests/l2cap-build/main/CMakeLists.txt new file mode 100644 index 00000000..d13d789e --- /dev/null +++ b/.github/tests/l2cap-build/main/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRCS "main.cpp" REQUIRES esp-nimble-cpp) diff --git a/.github/tests/l2cap-build/main/main.cpp b/.github/tests/l2cap-build/main/main.cpp new file mode 100644 index 00000000..4e0aea4c --- /dev/null +++ b/.github/tests/l2cap-build/main/main.cpp @@ -0,0 +1,49 @@ +#include "NimBLEDevice.h" +#include "NimBLEL2CAPServer.h" +#include "NimBLEL2CAPChannel.h" +#include "freertos/queue.h" +#include +#include + +// 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 data; +}; +static QueueHandle_t jobs; +static std::atomic generation{0}; + +class EchoCallbacks : public NimBLEL2CAPChannelCallbacks { + void onConnect(NimBLEL2CAPChannel*, uint16_t) override { ++generation; } + void onDisconnect(NimBLEL2CAPChannel*) override { ++generation; } + void onRead(NimBLEL2CAPChannel* channel, std::vector& 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(); +} diff --git a/.github/tests/l2cap-build/sdkconfig.defaults b/.github/tests/l2cap-build/sdkconfig.defaults new file mode 100644 index 00000000..23e62ae2 --- /dev/null +++ b/.github/tests/l2cap-build/sdkconfig.defaults @@ -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 diff --git a/.github/tests/l2cap-build/sdkconfig.deferred b/.github/tests/l2cap-build/sdkconfig.deferred new file mode 100644 index 00000000..a0d55aee --- /dev/null +++ b/.github/tests/l2cap-build/sdkconfig.deferred @@ -0,0 +1 @@ +CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=y diff --git a/.github/tests/server-build/.gitignore b/.github/tests/server-build/.gitignore new file mode 100644 index 00000000..6006c87e --- /dev/null +++ b/.github/tests/server-build/.gitignore @@ -0,0 +1,5 @@ +build*/ +sdkconfig +sdkconfig.old +managed_components/ +dependencies.lock diff --git a/.github/tests/server-build/CMakeLists.txt b/.github/tests/server-build/CMakeLists.txt new file mode 100644 index 00000000..d29bd3ba --- /dev/null +++ b/.github/tests/server-build/CMakeLists.txt @@ -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_server_build) diff --git a/.github/tests/server-build/main/CMakeLists.txt b/.github/tests/server-build/main/CMakeLists.txt new file mode 100644 index 00000000..d13d789e --- /dev/null +++ b/.github/tests/server-build/main/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRCS "main.cpp" REQUIRES esp-nimble-cpp) diff --git a/.github/tests/server-build/main/main.cpp b/.github/tests/server-build/main/main.cpp new file mode 100644 index 00000000..44b6fb17 --- /dev/null +++ b/.github/tests/server-build/main/main.cpp @@ -0,0 +1,9 @@ +#include "NimBLEDevice.h" + +// Compile/link probe: exercise the server even when advertising is disabled. +extern "C" void app_main() { + NimBLEDevice::init("server-build-probe"); + auto* server = NimBLEDevice::createServer(); + server->advertiseOnDisconnect(false); + server->createService("180f"); +} diff --git a/.github/tests/server-build/sdkconfig.defaults b/.github/tests/server-build/sdkconfig.defaults new file mode 100644 index 00000000..f04f0215 --- /dev/null +++ b/.github/tests/server-build/sdkconfig.defaults @@ -0,0 +1,7 @@ +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_ROLE_CENTRAL=n +CONFIG_BT_NIMBLE_ROLE_OBSERVER=n +CONFIG_BT_NIMBLE_ROLE_PERIPHERAL=y +CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y +CONFIG_COMPILER_OPTIMIZATION_PERF=y diff --git a/.github/tests/server-build/sdkconfig.no-broadcaster b/.github/tests/server-build/sdkconfig.no-broadcaster new file mode 100644 index 00000000..b3dceb69 --- /dev/null +++ b/.github/tests/server-build/sdkconfig.no-broadcaster @@ -0,0 +1 @@ +CONFIG_BT_NIMBLE_ROLE_BROADCASTER=n diff --git a/.github/workflows/l2cap-benchmark.yml b/.github/workflows/l2cap-benchmark.yml new file mode 100644 index 00000000..080fa063 --- /dev/null +++ b/.github/workflows/l2cap-benchmark.yml @@ -0,0 +1,41 @@ +name: L2CAP benchmark +on: + workflow_dispatch: + pull_request: + push: + branches: [master] +permissions: + contents: read + +jobs: + evidence: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install pyserial + - run: python -m unittest discover -s tools/l2cap-bench -p 'test*.py' + firmware: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [esp32s3, esp32s31] + container: espressif/idf@sha256:81893c71bb5e570088901f21def8684c25cd2a9020281bd01b843a7655edb18c + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + path: esp-nimble-cpp + - name: Compile benchmark firmware + env: + IDF_TARGET: ${{ matrix.target }} + shell: bash + run: | + . "${IDF_PATH}/export.sh" + cd esp-nimble-cpp/examples/L2CAP/L2CAP_Bench + idf.py --preview -D "SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.$IDF_TARGET" build diff --git a/.github/workflows/l2cap-build.yml b/.github/workflows/l2cap-build.yml new file mode 100644 index 00000000..5731bade --- /dev/null +++ b/.github/workflows/l2cap-build.yml @@ -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 diff --git a/.github/workflows/l2cap-tests.yml b/.github/workflows/l2cap-tests.yml new file mode 100644 index 00000000..05e72a35 --- /dev/null +++ b/.github/workflows/l2cap-tests.yml @@ -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 diff --git a/.github/workflows/s31-build.yml b/.github/workflows/s31-build.yml new file mode 100644 index 00000000..95d0ae7a --- /dev/null +++ b/.github/workflows/s31-build.yml @@ -0,0 +1,43 @@ +name: S31 and server role builds + +on: + workflow_dispatch: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + server-build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [esp32s3, esp32s31] + broadcaster: [enabled, disabled] + # ESP-IDF v6.1; S31 still requires --preview. Stable 5.x jobs stay unchanged. + container: espressif/idf@sha256:81893c71bb5e570088901f21def8684c25cd2a9020281bd01b843a7655edb18c + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + path: esp-nimble-cpp + - name: Build server role probe + env: + IDF_TARGET: ${{ matrix.target }} + BROADCASTER: ${{ matrix.broadcaster }} + shell: bash + run: | + . "${IDF_PATH}/export.sh" + cd esp-nimble-cpp/.github/tests/server-build + defaults="sdkconfig.defaults" + if [ "$BROADCASTER" = disabled ]; then defaults="$defaults;sdkconfig.no-broadcaster"; fi + idf.py --preview -D "SDKCONFIG_DEFAULTS=$defaults" build + python - "$BROADCASTER" <<'PYTHON' + import sys + from pathlib import Path + enabled = "CONFIG_BT_NIMBLE_ROLE_BROADCASTER=y" in Path("sdkconfig").read_text().splitlines() + assert enabled == (sys.argv[1] == "enabled"), "role override was not applied" + PYTHON diff --git a/CMakeLists.txt b/CMakeLists.txt index f3bff50c..f5d73069 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -33,6 +33,7 @@ idf_component_register( REQUIRED_IDF_TARGETS "esp32" "esp32s3" + "esp32s31" "esp32c2" "esp32c3" "esp32c5" diff --git a/Kconfig b/Kconfig index bfd47962..f253211e 100644 --- a/Kconfig +++ b/Kconfig @@ -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 \ No newline at end of file +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 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..b44d0ee9 --- /dev/null +++ b/Makefile @@ -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 [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 diff --git a/README.md b/README.md index ea6b8976..64a3d82d 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ When using this library along with Arduino and compiling with *CMake* you must a in your project/CMakeLists.txt after the line `include($ENV{IDF_PATH}/tools/cmake/project.cmake)` to prevent Arduino from releasing BLE memory.
+## L2CAP measurement + +The standalone [CoC benchmark](examples/L2CAP/L2CAP_Bench/README.md) for ESP-IDF +6.1 supports ESP32-S3/S31, ESP and macOS peers, request/response and streaming, +and repeated reconnects. Its [serial runner](tools/l2cap-bench/README.md) +retains accepted link parameters and validates complete per-frame evidence. +Native channel regression tests are documented in [tests/l2cap](tests/l2cap/README.md). + # Sponsors Thank you to all the sponsors who support this project! diff --git a/examples/L2CAP/L2CAP_Bench/.gitignore b/examples/L2CAP/L2CAP_Bench/.gitignore new file mode 100644 index 00000000..221ade48 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/.gitignore @@ -0,0 +1,5 @@ +build*/ +sdkconfig +sdkconfig.old +dependencies.lock +managed_components/ diff --git a/examples/L2CAP/L2CAP_Bench/CMakeLists.txt b/examples/L2CAP/L2CAP_Bench/CMakeLists.txt new file mode 100644 index 00000000..e93c34a8 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.16) +get_filename_component(NIMBLE_CPP_ROOT "../../.." ABSOLUTE) +set(EXTRA_COMPONENT_DIRS "${NIMBLE_CPP_ROOT}") +set(COMPONENTS main) +set(SUPPORTED_TARGETS esp32s3 esp32s31) +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +project(l2cap_bench) diff --git a/examples/L2CAP/L2CAP_Bench/README.md b/examples/L2CAP/L2CAP_Bench/README.md new file mode 100644 index 00000000..a41ed8ff --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/README.md @@ -0,0 +1,118 @@ +# L2CAP CoC benchmark + +Standalone ESP-IDF 6.1 firmware for ESP32-S3 and ESP32-S31, with `echo`, +small-request/large-response `request`, `stream-tx`, `stream-rx` and `duplex`. +It can be the DUT or the ESP reflector. Wi-Fi/Ethernet are not initialized. +The ESP drives the application protocol independently of its GAP role. + +## Build and connect + +```sh +# Use the matching ESP-IDF 6.1 environment. +idf.py --version +IDF_TARGET=esp32s3 idf.py -B build-s3 -D SDKCONFIG=build-s3/sdkconfig \ + -D 'SDKCONFIG_DEFAULTS=sdkconfig.defaults;sdkconfig.defaults.esp32s3' build +idf.py -B build-s3 -p /dev/serial/by-id/ flash +``` + +For S31 use `IDF_TARGET=esp32s31`, `idf.py --preview`, `build-s31` and +`sdkconfig.defaults.esp32s31`. CPU defaults are 240/320 MHz respectively. +The USB Serial/JTAG console accepts LF-terminated commands at 115200 baud. + +An ESP peer uses the same firmware and `role server`. For macOS use the +[`l2cap-reflect` companion](https://github.com/mickeyl/core-bluetooth-tool) +and its `tools/run-l2cap-reflector-app.sh`; macOS must grant Bluetooth access. +The companion shares Foundation-only framing/codecs for a future iPhone peer; +an iPhone peer is not included here. Start the Mac with +`--connect L2CAP-Bench-ESP` for Apple-Central/ESP-Peripheral roles. + +```text +params 15 2 +listen +await 30 +run echo 64 64 64 +run request 8 4096 64 +run duplex 4096 4096 128 +disconnect +``` + +For ESP-Central mode use `connect auto` instead of `listen`/`await`. Automatic +selection requires exactly one benchmark service; a name or BLE address can be +selected explicitly. `role client` restores DUT mode after using the ESP as a +reflector. A running reflector has a separate application TX worker. + +The Python runner automates this over USB; see +[`tools/l2cap-bench`](../../../tools/l2cap-bench/README.md). Use +`--require-params` for controlled comparisons. Link setup retries a refused +interval after one second, up to three times, outside callbacks and timing. +The peer still chooses the accepted parameters: requested values are not proof. + +## Wire contract, version 2 + +Frames are `B7 type length:u16 payload`, with at most 4996 payload bytes. +Multibyte fields are big endian except the GATT PSM's little-endian `u16`. +The parser accepts split and coalesced frames; SDUs are not message boundaries. +The service UUID is `e7100001-61b3-4b3d-8b4d-7e3b51e2c001`; its PSM read +characteristic is `e7100002-61b3-4b3d-8b4d-7e3b51e2c001`. + +| Type | Payload | +|---|---| +| `01 HELLO` / `81 reply` | Version `02` followed by UTF-8 identity. | +| `02 CONFIG` / `82 reply` | Pattern `u8`, request/response sizes `u16`, count `u32`; reply status `00` means accepted. Patterns: 1 echo, 2 request, 3 stream-tx, 4 stream-rx, 5 duplex. | +| `03 PING` / `83 reply` | Sequence/token `u32`, then deterministic data. Reply adds reflector turnaround `u32` microseconds after token. | +| `06 START` / `86 READY` | Run token `u32`; receive state is armed before READY. | +| `04 DATA` | Sequence/token `u32` followed by deterministic data. | +| `05 END` | Token/count `u32`. | +| `85 RECEIPT` | Token/count `u32`, received bytes/first timestamp/last timestamp `u64`, errors `u32` (36 bytes). | +| `07 FINISH` / `87 finished` | Token `u32`; confirmation requires all enabled directions and receipts complete. | +| `7F ABORT` | Optional UTF-8 reason. | + +CLI sizes include message metadata and exclude the four-byte frame header. +Echo/response/stream sizes are 12..4996 bytes; an asymmetric request may use 8. CONFIG count is 1..4096. Data byte `i` is +`(sequence + token + i) & 255`, starting after sequence/token (and turnaround in +a reply). Streaming has no per-DATA application ACK. A failed/incomplete write +or validation aborts the run. There is a benchmark-only 10-second receive +no-progress watchdog; this is not an ECU diagnostic timeout. + +## Metrics and interpretation + +`hello` identifies Git/IDF bases, SDK-config and ELF hashes, CPU, buffer counts +and options. Per-run parameters include actual interval, PHY, MTU and observed +DLE. Records use blocking USB writes after timing; all timed per-frame DUT +samples are retained in memory and emitted afterwards. Reflector writes never +run in a native receive callback. Fast streaming validation runs in that +callback without console output or writes. + +Echo/request RTT ends at complete reply parsing on the DUT clock. The first +callback timestamp means delivery of a complete SDU, not first RF byte. Peer +turnaround is measured on its own clock and its subtraction still leaves a +round trip. `tx_submit_us` measures blocking host submission, not remote use. + +Stream wall goodput divides useful pattern bytes in all enabled directions by +START-through-FINISH duration, including control round trips. Arrival goodput +uses each receiver's own first/last timestamps, excludes the first frame, and +never compares clocks across peers. Neither metric is physical BLE throughput. + +Reconnect cycles close CoC/GAP, allow 100 ms for RTOS cleanup and report total/ +internal free heap, largest internal block and tasks. The runner excludes five +initial cycles from heap trends. Parser capacity may retain a bounded high-water +allocation; compare settled cycles within one build/role. Short runs do not +prove day-long leak freedom. Failed files remain failed evidence. + +## Known resource limit + +With very large peer credits, unpaced large SDUs can exhaust native HCI/MSYS +buffers. CoC credit availability does not bound local controller backlog. +The library aborts the incomplete write instead of replaying a partial SDU. +This benchmark deliberately reports that failure; it does not silently pace or +retry the run. Experimental resource-window, alignment and native diagnostic +changes are outside this submission. Use echo/request as a bounded-load +baseline and preserve failed streaming captures when investigating the limit. + +The default pool profile also failed an ESP-S3→ESP-S31 single-request echo at +4996 payload bytes plus framing (one 5000-byte SDU, negotiated MTU 5000). An +error-only instrumented check returned code 6 (ENOMEM) at the first write. That +identifies a resource failure, not the exact limiting pool; it is retained as a +failed case and is not claimed fixed here. Use 4096-byte payloads for the bounded +baseline. The parser's maximum frame size is not a guarantee that the default +host/controller buffers can sustain every transfer geometry. diff --git a/examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt b/examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt new file mode 100644 index 00000000..8500df14 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register(SRCS "main.cpp" INCLUDE_DIRS "." REQUIRES esp-nimble-cpp console esp_driver_usb_serial_jtag esp_timer espressif__cjson app_update) +execute_process(COMMAND git -C "${CMAKE_CURRENT_LIST_DIR}/../../../.." rev-parse HEAD OUTPUT_VARIABLE BENCH_LIBRARY_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE) +execute_process(COMMAND git -C "$ENV{IDF_PATH}" rev-parse HEAD OUTPUT_VARIABLE BENCH_IDF_COMMIT OUTPUT_STRIP_TRAILING_WHITESPACE) +file(SHA256 "${SDKCONFIG}" BENCH_CONFIG_HASH) +configure_file(bench_build.hpp.in "${CMAKE_CURRENT_BINARY_DIR}/bench_build.hpp" @ONLY) +target_include_directories(${COMPONENT_LIB} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") diff --git a/examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in b/examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in new file mode 100644 index 00000000..ce3d415e --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/main/bench_build.hpp.in @@ -0,0 +1,4 @@ +#pragma once +#define BENCH_LIBRARY_COMMIT "@BENCH_LIBRARY_COMMIT@" +#define BENCH_IDF_COMMIT "@BENCH_IDF_COMMIT@" +#define BENCH_CONFIG_HASH "@BENCH_CONFIG_HASH@" diff --git a/examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp b/examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp new file mode 100644 index 00000000..f6bdd786 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp @@ -0,0 +1,126 @@ +#pragma once +#include +#include +#include + +namespace bench { +constexpr uint8_t magic = 0xB7; +constexpr size_t maxPayload = 4996; +constexpr uint8_t hello = 1, config = 2, ping = 3, abort = 0x7F; +constexpr uint8_t helloReply = 0x81, configReply = 0x82, pingReply = 0x83; +constexpr uint8_t version = 2; +constexpr uint8_t stream = 4, streamEnd = 5, streamStart = 6, streamFinish = 7; +constexpr uint8_t streamEndReply = 0x85, streamStartReply = 0x86, streamFinishReply = 0x87; +struct Frame { uint8_t type; std::vector payload; int64_t receivedUs = 0; uint32_t generation = 0; }; +inline uint32_t u32(const uint8_t* p) { + return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | p[3]; +} +inline void put32(std::vector& p, uint32_t v) { + p.push_back(v >> 24); p.push_back(v >> 16); p.push_back(v >> 8); p.push_back(v); +} +inline uint64_t u64(const uint8_t* p) { return (uint64_t(u32(p)) << 32) | u32(p + 4); } +inline void put64(std::vector& p, uint64_t v) { put32(p, v >> 32); put32(p, uint32_t(v)); } +inline uint8_t pattern(uint32_t sequence, uint32_t token, size_t offset) { + return static_cast(sequence + token + offset); +} +inline std::vector encode(uint8_t type, const std::vector& payload) { + std::vector result{magic, type, uint8_t(payload.size() >> 8), uint8_t(payload.size())}; + result.insert(result.end(), payload.begin(), payload.end()); + return result; +} +class Parser { + std::vector bytes; + bool failed = false; +public: + void reset() { bytes.clear(); failed = false; } + template bool feed(const std::vector& incoming, Emit emit) { + if (failed) return false; + // Process incrementally: a read may contain multiple complete frames. + for (auto byte : incoming) { + bytes.push_back(byte); + if (bytes.size() == 1 && byte != magic) { failed = true; return false; } + if (bytes.size() < 4) continue; + const size_t length = (size_t(bytes[2]) << 8) | bytes[3]; + if (length > maxPayload) { failed = true; return false; } + if (bytes.size() == length + 4) { + emit(Frame{bytes[1], {bytes.begin() + 4, bytes.end()}}); + bytes.clear(); + } + } + return true; + } +}; +struct Config { + uint8_t pattern = 0; + uint16_t request = 0, response = 0; + uint32_t count = 0; + bool parse(const std::vector& p) { + if (p.size() != 9) return false; + Config candidate{p[0], uint16_t((uint16_t(p[1]) << 8) | p[2]), + uint16_t((uint16_t(p[3]) << 8) | p[4]), u32(p.data() + 5)}; + if (candidate.pattern < 1 || candidate.pattern > 5 || candidate.request < 8 || candidate.request > maxPayload || + candidate.response < 12 || candidate.response > maxPayload || candidate.count == 0 || candidate.count > 4096 || + (candidate.pattern == 1 && candidate.request != candidate.response)) return false; + *this = candidate; + return true; + } + std::vector encode() const { + std::vector p{pattern, uint8_t(request >> 8), uint8_t(request), uint8_t(response >> 8), uint8_t(response)}; + put32(p, count); return p; + } +}; + +// These are application stream frames, independent of SDU boundaries. +inline std::vector streamPayload(uint32_t seq, uint32_t token, uint16_t size) { + std::vector p; p.reserve(size); put32(p, seq); put32(p, token); + for (size_t i = 8; i < size; ++i) p.push_back(pattern(seq, token, i - 8)); + return p; +} +struct StreamSummary { + uint32_t token = 0, frames = 0; + uint64_t bytes = 0, firstUs = 0, lastUs = 0; + uint32_t errors = 0; + std::vector encode() const { + std::vector p; put32(p, token); put32(p, frames); put64(p, bytes); + put64(p, firstUs); put64(p, lastUs); put32(p, errors); return p; + } + bool parse(const std::vector& p) { + if (p.size() != 36) return false; + *this = {u32(p.data()), u32(p.data() + 4), u64(p.data() + 8), + u64(p.data() + 16), u64(p.data() + 24), u32(p.data() + 32)}; + return firstUs <= lastUs; + } + bool matches(uint32_t expectedToken, uint32_t count, uint16_t size) const { + return token == expectedToken && frames == count && bytes == uint64_t(count) * size && + errors == 0 && firstUs <= lastUs; + } +}; +struct StreamReceiver { + StreamSummary summary; + uint32_t count = 0; + uint16_t size = 0; + bool ended = false; + std::vector arrivals; + void reset(uint32_t token, uint32_t expectedCount, uint16_t expectedSize) { + summary = {}; summary.token = token; count = expectedCount; size = expectedSize; + ended = false; arrivals.clear(); arrivals.reserve(expectedCount); + } + bool receive(const std::vector& p, uint64_t now) { + bool valid = !ended && summary.frames < count && p.size() == size && p.size() >= 8 && + u32(p.data()) == summary.frames && u32(p.data() + 4) == summary.token; + for (size_t i = 8; valid && i < p.size(); ++i) + valid = p[i] == pattern(summary.frames, summary.token, i - 8); + if (!valid) { ++summary.errors; return false; } + if (!summary.frames) summary.firstUs = now; + summary.lastUs = now; ++summary.frames; summary.bytes += p.size(); arrivals.push_back(now); + return true; + } + bool end(const std::vector& p) { + const bool valid = !ended && p.size() == 8 && u32(p.data()) == summary.token && + u32(p.data() + 4) == count && summary.matches(summary.token, count, size); + ended = true; + if (!valid) ++summary.errors; + return valid; + } +}; +} // namespace bench diff --git a/examples/L2CAP/L2CAP_Bench/main/bench_stream_run.inc b/examples/L2CAP/L2CAP_Bench/main/bench_stream_run.inc new file mode 100644 index 00000000..b9cc856c --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/main/bench_stream_run.inc @@ -0,0 +1,106 @@ +// Included by main.cpp inside the benchmark namespace. No product protocol code. +bool sendStream(NimBLEL2CAPChannel* channel, uint32_t generation, uint32_t token, + uint32_t count, uint16_t size, std::vector* submits = nullptr) { + for (uint32_t seq = 0; seq < count; ++seq) { + if (!connected.load() || generation != connectionGeneration.load()) return false; + const auto payload = bench::encode(bench::stream, bench::streamPayload(seq, token, size)); + const auto started = esp_timer_get_time(); + if (!channel->write(payload)) return false; + if (submits) submits->push_back(uint32_t(esp_timer_get_time() - started)); + } + std::vector end; bench::put32(end, token); bench::put32(end, count); + return generation == connectionGeneration.load() && channel->write(bench::encode(bench::streamEnd, end)); +} + +void addSummary(cJSON* parent, const char* key, const bench::StreamSummary& summary) { + auto object = cJSON_CreateObject(); + number(object, "token", summary.token); number(object, "frames", summary.frames); + number(object, "bytes", summary.bytes); number(object, "first_us", summary.firstUs); + number(object, "last_us", summary.lastUs); number(object, "errors", summary.errors); + cJSON_AddItemToObject(parent, key, object); +} + +int runStream(const bench::Config& config, const char* name) { + const bool send = config.pattern != 4, receive = config.pattern != 3; + const uint32_t token = esp_random(), generation = connectionGeneration.load(); + const auto faults = callbackFaults.load(); + { + std::lock_guard lock(streamMutex); + streamReceiver.reset(token, receive ? config.count : 0, config.response); + } + std::vector submits; if (send) submits.reserve(config.count); + auto begin = cJSON_CreateObject(); + cJSON_AddStringToObject(begin, "pattern", name); + number(begin, "count", config.count); number(begin, "request_size", config.request); + number(begin, "response_size", config.response); number(begin, "token", token); emit("run-start", begin); + std::vector tokenPayload; bench::put32(tokenPayload, token); + const auto started = esp_timer_get_time(); + auto ready = exchange(bench::streamStart, tokenPayload, bench::streamStartReply); + bool success = ready && ready->payload == tokenPayload; + auto channel = activeChannel.load(); + const auto txStarted = esp_timer_get_time(); + if (success && send) success = channel && sendStream(channel, generation, token, config.count, config.request, &submits); + const auto txSubmitted = esp_timer_get_time(); + bool rxEnded = !receive, txConfirmed = !send; + bench::StreamSummary peerSummary; + const auto waitStarted = esp_timer_get_time(); + while (success && (!rxEnded || !txConfirmed)) { + bench::Frame* raw = nullptr; + if (xQueueReceive(replies, &raw, pdMS_TO_TICKS(100)) != pdTRUE) { + uint64_t progress; + { std::lock_guard lock(streamMutex); progress = streamReceiver.summary.lastUs; } + // A benchmark watchdog, not an ECU response timer: valid RX data + // refreshes it, including while the two duplex streams differ. + if (!connected.load() || esp_timer_get_time() - std::max(waitStarted, progress) > 10000000) success = false; + continue; + } + std::unique_ptr frame(raw); + if (frame->type == bench::streamEnd && !rxEnded) { + std::vector reply; + { + std::lock_guard lock(streamMutex); + success = streamReceiver.end(frame->payload); + reply = streamReceiver.summary.encode(); + } + rxEnded = true; + if (success) success = channel && channel->write(bench::encode(bench::streamEndReply, reply)); + } else if (frame->type == bench::streamEndReply && !txConfirmed) { + success = peerSummary.parse(frame->payload) && peerSummary.matches(token, config.count, config.request); + txConfirmed = true; + } else success = false; + } + if (success) { + auto finished = exchange(bench::streamFinish, tokenPayload, bench::streamFinishReply); + success = finished && finished->payload == tokenPayload; + } + const auto finished = esp_timer_get_time(); + bench::StreamReceiver received; + { std::lock_guard lock(streamMutex); received = std::move(streamReceiver); streamReceiver = {}; } + success = success && callbackFaults.load() == faults && generation == connectionGeneration.load() && + (!receive || (received.ended && received.summary.matches(token, config.count, config.response))) && + (!send || submits.size() == config.count); + params(); + auto result = cJSON_CreateObject(); + cJSON_AddStringToObject(result, "pattern", name); cJSON_AddBoolToObject(result, "success", success); + number(result, "completed", success ? config.count : 0); number(result, "token", token); + number(result, "tx_frames", submits.size()); number(result, "rx_frames", received.summary.frames); + number(result, "callback_faults", callbackFaults.load() - faults); + number(result, "elapsed_us", finished - started); number(result, "tx_submit_span_us", txSubmitted - txStarted); + number(result, "goodput_bytes_per_s", double(submits.size() * (config.request - 8) + received.summary.frames * (config.response - 8)) * 1e6 / (finished - started)); + addSummary(result, "local_rx", received.summary); addSummary(result, "peer_rx", peerSummary); + cJSON_AddBoolToObject(result, "peer_finish_confirmed", success); + if (send && peerSummary.frames > 1 && peerSummary.lastUs > peerSummary.firstUs) + number(result, "tx_arrival_goodput_bytes_per_s", double(peerSummary.frames - 1) * (config.request - 8) * 1e6 / (peerSummary.lastUs - peerSummary.firstUs)); + if (receive && received.summary.frames > 1 && received.summary.lastUs > received.summary.firstUs) + number(result, "rx_arrival_goodput_bytes_per_s", double(received.summary.frames - 1) * (config.response - 8) * 1e6 / (received.summary.lastUs - received.summary.firstUs)); + emit("run-result", result); + for (size_t i = 0; i < submits.size(); ++i) { + auto sample = cJSON_CreateObject(); number(sample, "seq", i); number(sample, "tx_submit_us", submits[i]); emit("stream-tx-sample", sample); + } + for (size_t i = 0; i < received.arrivals.size(); ++i) { + auto sample = cJSON_CreateObject(); number(sample, "seq", i); number(sample, "received_at_us", received.arrivals[i]); emit("stream-rx-sample", sample); + } + emit("run-end"); + if (!success) { error("stream data, receipt, finish or watchdog failure"); disconnectClient(); } + return success ? 0 : 1; +} diff --git a/examples/L2CAP/L2CAP_Bench/main/idf_component.yml b/examples/L2CAP/L2CAP_Bench/main/idf_component.yml new file mode 100644 index 00000000..e83e7109 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/main/idf_component.yml @@ -0,0 +1,4 @@ +dependencies: + idf: ">=6.1" + espressif/cjson: + version: "^1.7.19" diff --git a/examples/L2CAP/L2CAP_Bench/main/main.cpp b/examples/L2CAP/L2CAP_Bench/main/main.cpp new file mode 100644 index 00000000..0ae19e69 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/main/main.cpp @@ -0,0 +1,587 @@ +#include "bench_protocol.hpp" +#include "bench_build.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr auto serviceUuid = "e7100001-61b3-4b3d-8b4d-7e3b51e2c001"; +constexpr auto psmUuid = "e7100002-61b3-4b3d-8b4d-7e3b51e2c001"; +constexpr uint16_t serverPsm = 192, channelMtu = 5000; +QueueHandle_t replies, reflectRequests; +NimBLEClient* client = nullptr; +NimBLEL2CAPChannel* ownedChannel = nullptr; +std::atomic activeChannel{nullptr}; +std::atomic serverMode{false}, connected{false}, waiting{false}; +std::atomic reflectorMode{false}, advertisingEnabled{false}; +std::atomic gapHandle{BLE_HS_CONN_HANDLE_NONE}; +std::atomic connectionGeneration{0}; +bool serverStarted = false; +std::atomic firstCallbackUs{0}, callbackFaults{0}; +std::atomic negotiatedMtu{0}; +std::atomic observedTxOctets{0}, observedRxOctets{0}; +std::atomic intervalStatus{0}; +std::atomic reflectorBusy{false}; +std::mutex streamMutex; +bench::StreamReceiver streamReceiver; +int64_t requestStartedUs = 0; +uint16_t intervalUnits = 12; +uint8_t wantedPhy = BLE_GAP_LE_PHY_2M; + +void emit(const char* event, cJSON* object = nullptr) { + if (!object) object = cJSON_CreateObject(); + cJSON_AddStringToObject(object, "event", event); + char* line = cJSON_PrintUnformatted(object); + if (line) { + const std::string output = std::string("BENCH ") + line + "\n"; + // IDF's stdout VFS can silently discard characters after a USB TX + // timeout. Benchmark evidence must be complete; these writes occur + // outside the measured request loop and can wait for the host. + size_t offset = 0; + while (offset < output.size()) { + const size_t size = std::min(512, output.size() - offset); + const int written = usb_serial_jtag_write_bytes(output.data() + offset, size, portMAX_DELAY); + if (written > 0) offset += written; + } + cJSON_free(line); + } + cJSON_Delete(object); +} +void error(const char* message) { + auto object = cJSON_CreateObject(); + cJSON_AddStringToObject(object, "message", message); + emit("error", object); +} +void number(cJSON* object, const char* key, double value) { cJSON_AddNumberToObject(object, key, value); } +void clearQueue(QueueHandle_t queue) { + bench::Frame* frame; + while (xQueueReceive(queue, &frame, 0) == pdTRUE) delete frame; +} +void clearStream() { std::lock_guard lock(streamMutex); streamReceiver = {}; } +void heapStatus(const char* phase) { + auto object = cJSON_CreateObject(); + cJSON_AddStringToObject(object, "phase", phase); + number(object, "free", esp_get_free_heap_size()); + number(object, "minimum_ever", esp_get_minimum_free_heap_size()); + number(object, "largest_internal_block", heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + number(object, "internal_free", heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + number(object, "tasks", uxTaskGetNumberOfTasks()); emit("heap", object); +} + +class ChannelCallbacks : public NimBLEL2CAPChannelCallbacks { + bench::Parser parser; +public: + void onConnect(NimBLEL2CAPChannel* channel, uint16_t mtu) override { + ++connectionGeneration; + clearQueue(replies); clearQueue(reflectRequests); + clearStream(); + parser.reset(); negotiatedMtu = mtu; activeChannel = channel; connected = true; + } + void onRead(NimBLEL2CAPChannel*, std::vector& bytes) override { + const auto now = esp_timer_get_time(); + if (waiting.load()) { + uint32_t empty = 0; + firstCallbackUs.compare_exchange_strong(empty, uint32_t(now - requestStartedUs)); + } + if (!parser.feed(bytes, [now](bench::Frame frame) { + frame.receivedUs = now; + frame.generation = connectionGeneration.load(); + if (frame.type == bench::stream) { + std::lock_guard lock(streamMutex); + if (!streamReceiver.receive(frame.payload, now)) ++callbackFaults; + return; + } + auto pending = new bench::Frame(std::move(frame)); + auto queue = reflectorMode.load() ? reflectRequests : replies; + if (xQueueSend(queue, &pending, 0) != pdTRUE) { delete pending; ++callbackFaults; } + })) ++callbackFaults; + } + void onDisconnect(NimBLEL2CAPChannel*) override { + connected = false; activeChannel = nullptr; + ++connectionGeneration; + } +}; + +class ClientCallbacks : public NimBLEClientCallbacks { + void onConnect(NimBLEClient* peer) override { gapHandle = peer->getConnHandle(); peer->setDataLen(251); } + void onDisconnect(NimBLEClient*, int) override { gapHandle = BLE_HS_CONN_HANDLE_NONE; } +} clientCallbacks; + +class ServerCallbacks : public NimBLEServerCallbacks { + void onConnect(NimBLEServer* server, NimBLEConnInfo& info) override { + gapHandle = info.getConnHandle(); + server->setDataLen(info.getConnHandle(), 251); + server->updateConnParams(info.getConnHandle(), intervalUnits, intervalUnits, 0, 200); + } + void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override { + gapHandle = BLE_HS_CONN_HANDLE_NONE; + if (advertisingEnabled.load()) NimBLEDevice::startAdvertising(); + } +}; + +void metadata() { + auto object = cJSON_CreateObject(); + cJSON_AddStringToObject(object, "target", CONFIG_IDF_TARGET); + number(object, "bench_protocol", bench::version); + cJSON_AddStringToObject(object, "gap_role", serverMode.load() ? "peripheral" : "central"); + cJSON_AddStringToObject(object, "app_role", reflectorMode.load() ? "reflector" : "dut"); + cJSON_AddStringToObject(object, "library_commit", BENCH_LIBRARY_COMMIT); + cJSON_AddStringToObject(object, "idf_commit", BENCH_IDF_COMMIT); + cJSON_AddStringToObject(object, "sdkconfig_sha256", BENCH_CONFIG_HASH); + const auto app = esp_app_get_description(); + char hash[65]; + for (size_t i = 0; i < 32; ++i) snprintf(hash + 2 * i, 3, "%02x", app->app_elf_sha256[i]); + cJSON_AddStringToObject(object, "elf_sha256", hash); + cJSON_AddStringToObject(object, "ble_address", NimBLEDevice::getAddress().toString().c_str()); + number(object, "cpu_mhz", CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ); + number(object, "msys1_block_size", CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE); + number(object, "msys1_block_count", CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT); + number(object, "msys2_block_size", CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE); + number(object, "msys2_block_count", CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT); + number(object, "acl_buf_count", CONFIG_BT_NIMBLE_ACL_BUF_COUNT); + number(object, "sdu_buffers", CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT); +#ifdef CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS + cJSON_AddBoolToObject(object, "deferred_callbacks", true); +#else + cJSON_AddBoolToObject(object, "deferred_callbacks", false); +#endif +#if defined(CONFIG_COMPILER_OPTIMIZATION_PERF) + cJSON_AddStringToObject(object, "optimization", "performance"); +#elif defined(CONFIG_COMPILER_OPTIMIZATION_SIZE) + cJSON_AddStringToObject(object, "optimization", "size"); +#else + cJSON_AddStringToObject(object, "optimization", "debug"); +#endif + emit("hello", object); +} + +void params() { + auto object = cJSON_CreateObject(); + cJSON_AddBoolToObject(object, "connected", connected.load()); + cJSON_AddStringToObject(object, "gap_role", serverMode.load() ? "peripheral" : "central"); + number(object, "mtu", negotiatedMtu.load()); + number(object, "requested_interval_ms", intervalUnits * 1.25); + number(object, "requested_phy", wantedPhy); + number(object, "requested_tx_octets", 251); + if (observedTxOctets.load()) { + number(object, "tx_octets", observedTxOctets.load()); + number(object, "rx_octets", observedRxOctets.load()); + } + ble_gap_conn_desc info{}; + const auto handle = gapHandle.load(); + if (handle != BLE_HS_CONN_HANDLE_NONE && ble_gap_conn_find(handle, &info) == 0) { + number(object, "interval_ms", info.conn_itvl * 1.25); + number(object, "latency", info.conn_latency); + number(object, "supervision_ms", info.supervision_timeout * 10); + int8_t rssi = 0; + if (ble_gap_conn_rssi(handle, &rssi) == 0) number(object, "rssi_dbm", rssi); + uint8_t txPhy = 0, rxPhy = 0; + if (ble_gap_read_le_phy(handle, &txPhy, &rxPhy) == 0) { + number(object, "tx_phy", txPhy); number(object, "rx_phy", rxPhy); + } + } + emit("params", object); +} + +bool disconnectClient() { + waiting = false; + advertisingEnabled = false; + if (serverStarted) NimBLEDevice::stopAdvertising(); + auto channel = activeChannel.load(); + if (!channel) channel = ownedChannel; + if (channel) channel->disconnect(); + if (serverMode.load() && gapHandle.load() != BLE_HS_CONN_HANDLE_NONE) + NimBLEDevice::getServer()->disconnect(gapHandle.load()); + if (client && client->isConnected()) client->disconnect(); + const auto deadline = esp_timer_get_time() + 3000000; + while (((channel && !channel->isIdle()) || gapHandle.load() != BLE_HS_CONN_HANDLE_NONE || reflectorBusy.load()) && + esp_timer_get_time() < deadline) vTaskDelay(1); + if ((channel && !channel->isIdle()) || gapHandle.load() != BLE_HS_CONN_HANDLE_NONE || reflectorBusy.load()) { + error("channel teardown still busy"); return false; + } + delete ownedChannel; ownedChannel = nullptr; + if (client) { + NimBLEDevice::deleteClient(client); client = nullptr; + // Scan results include unrelated nearby advertisers. Keeping them makes + // post-teardown heap depend on the last scan rather than channel leaks. + NimBLEDevice::getScan()->clearResults(); + } + activeChannel = nullptr; connected = false; + negotiatedMtu = 0; observedTxOctets = 0; observedRxOctets = 0; + intervalStatus = 0; + clearQueue(replies); clearQueue(reflectRequests); clearStream(); + return true; +} + +std::unique_ptr exchange(uint8_t type, const std::vector& payload, + uint8_t expected, uint32_t timeoutMs = 5000) { + auto channel = activeChannel.load(); + if (!channel || !connected.load()) { error("no L2CAP connection"); return {}; } + if (!channel->write(bench::encode(type, payload))) { error("L2CAP write failed"); return {}; } + bench::Frame* frame = nullptr; + if (xQueueReceive(replies, &frame, pdMS_TO_TICKS(timeoutMs)) != pdTRUE) { + error("response timeout"); return {}; + } + std::unique_ptr result(frame); + if (frame->type != expected) { error("unexpected response type"); return {}; } + return result; +} + +bool greetPeer(uint16_t psm) { + for (int retry = 0; retry < 3; ++retry) { + if (ble_gap_set_prefered_le_phy(gapHandle.load(), wantedPhy, wantedPhy, 0) == 0) break; + vTaskDelay(pdMS_TO_TICKS(100)); + } + vTaskDelay(pdMS_TO_TICKS(500)); + // Setup only: the product firmware retries transient interval collisions + // too. Never wait or retry on a GAP callback, and never assume acceptance. + for (int attempt = 1; attempt <= 3; ++attempt) { + ble_gap_conn_desc info{}; + const auto handle = gapHandle.load(); + if (ble_gap_conn_find(handle, &info) != 0 || + (info.conn_itvl == intervalUnits && info.conn_latency == 0)) break; + vTaskDelay(pdMS_TO_TICKS(1000)); + ble_gap_upd_params update{}; + update.itvl_min = update.itvl_max = intervalUnits; + update.latency = 0; update.supervision_timeout = 200; + const int priorStatus = intervalStatus.load(); + intervalStatus = -1; + const int rc = ble_gap_update_params(handle, &update); + const auto deadline = esp_timer_get_time() + 1000000; + if (rc == 0 || rc == BLE_HS_EALREADY || rc == BLE_HS_EBUSY) { + while (esp_timer_get_time() < deadline) { + if (ble_gap_conn_find(handle, &info) != 0 || info.conn_itvl == intervalUnits) break; + if (intervalStatus.load() != -1) break; + vTaskDelay(pdMS_TO_TICKS(10)); + } + } + auto object = cJSON_CreateObject(); + number(object, "attempt", attempt); number(object, "request_rc", rc); + number(object, "prior_status", priorStatus); + number(object, "completion_status", intervalStatus.load()); + number(object, "actual_interval_ms", info.conn_itvl * 1.25); emit("interval-update", object); + const int status = rc != 0 ? rc : intervalStatus.load(); + const bool transient = status == -1 || status == BLE_HS_EBUSY || status == BLE_HS_EALREADY || + status == BLE_HS_HCI_ERR(BLE_ERR_DIFF_TRANS_COLL) || status == BLE_HS_HCI_ERR(BLE_ERR_LMP_COLLISION); + if (!transient) break; + } + std::string identity = std::string(CONFIG_IDF_TARGET) + " " + BENCH_LIBRARY_COMMIT; + std::vector hello{bench::version}; hello.insert(hello.end(), identity.begin(), identity.end()); + auto answer = exchange(bench::hello, hello, bench::helloReply); + if (!answer || answer->payload.empty() || answer->payload[0] != bench::version) { + error("HELLO failed"); disconnectClient(); return false; + } + auto object = cJSON_CreateObject(); + const std::string peer(answer->payload.begin() + 1, answer->payload.end()); + cJSON_AddStringToObject(object, "peer_identity", peer.c_str()); + cJSON_AddStringToObject(object, "gap_role", serverMode.load() ? "peripheral" : "central"); + number(object, "psm", psm); emit("connected", object); params(); return true; +} + +bool connectPeer(const char* name) { + if (!disconnectClient()) return false; + serverMode = false; reflectorMode = false; + auto scan = NimBLEDevice::getScan(); + scan->setActiveScan(true); scan->clearResults(); + const auto results = scan->getResults(5000); + const NimBLEAdvertisedDevice* found = nullptr; + size_t matches = 0; + for (auto peer : results) { + const bool serviceMatch = peer->isAdvertisingService(NimBLEUUID(serviceUuid)); + const bool identityMatch = (peer->haveName() && peer->getName() == name) || peer->getAddress().toString() == name; + const bool selected = !strcmp(name, "auto") ? serviceMatch : identityMatch; + if (!serviceMatch && !identityMatch) continue; + auto object = cJSON_CreateObject(); + cJSON_AddStringToObject(object, "name", peer->getName().c_str()); + cJSON_AddStringToObject(object, "address", peer->getAddress().toString().c_str()); + cJSON_AddBoolToObject(object, "bench_service", serviceMatch); + number(object, "rssi_dbm", peer->getRSSI()); emit("discovered", object); + if (selected) { found = peer; ++matches; } + } + if (matches > 1) { error("multiple benchmark reflectors; select a name or BLE address"); return false; } + if (!found) { error("named reflector not found"); return false; } + client = NimBLEDevice::createClient(); + client->setClientCallbacks(&clientCallbacks, false); + client->setConnectionParams(intervalUnits, intervalUnits, 0, 200); + client->setConnectTimeout(10000); + if (!client->connect(found)) { error("GAP connect failed"); disconnectClient(); return false; } + auto service = client->getService(serviceUuid); + auto characteristic = service ? service->getCharacteristic(psmUuid) : nullptr; + const auto value = characteristic ? characteristic->readValue() : NimBLEAttValue(); + if (value.size() != 2) { error("PSM discovery failed"); disconnectClient(); return false; } + const auto psm = uint16_t(value[0]) | (uint16_t(value[1]) << 8); + ownedChannel = NimBLEL2CAPChannel::connect(client, psm, channelMtu, new ChannelCallbacks()); + if (!ownedChannel) { error("L2CAP connect failed"); disconnectClient(); return false; } + return greetPeer(psm); +} + +#include "bench_stream_run.inc" + +struct Sample { uint32_t sequence, rtt, first, submit, turnaround; }; +int run(int argc, char** argv) { + if (argc != 5) { error("usage: run echo|request|stream-tx|stream-rx|duplex request_size response_size count"); return 1; } + const long req = strtol(argv[2], nullptr, 10), resp = strtol(argv[3], nullptr, 10), count = strtol(argv[4], nullptr, 10); + const uint8_t pattern = !strcmp(argv[1], "echo") ? 1 : !strcmp(argv[1], "request") ? 2 : + !strcmp(argv[1], "stream-tx") ? 3 : !strcmp(argv[1], "stream-rx") ? 4 : !strcmp(argv[1], "duplex") ? 5 : 0; + if (!pattern || req < 8 || req > bench::maxPayload || resp < 12 || resp > bench::maxPayload || + count < 1 || count > 4096 || (pattern == 1 && req != resp)) { + error("invalid pattern, sizes or count (1..4096)"); return 1; + } + if (!connected.load() || reflectorMode.load()) { error("connect to a reflector first"); return 1; } + params(); + bench::Config config{pattern, uint16_t(req), uint16_t(resp), uint32_t(count)}; + auto ack = exchange(bench::config, config.encode(), bench::configReply); + if (!ack || ack->payload != std::vector{0}) { disconnectClient(); return 1; } + if (pattern >= 3) return runStream(config, argv[1]); + auto startObject = cJSON_CreateObject(); + cJSON_AddStringToObject(startObject, "pattern", argv[1]); + number(startObject, "request_size", req); number(startObject, "response_size", resp); number(startObject, "count", count); + number(startObject, "heap_free", esp_get_free_heap_size()); emit("run-start", startObject); + std::vector samples; samples.reserve(count); + const uint32_t token = esp_random(); + const auto faultsBefore = callbackFaults.load(); + const auto started = esp_timer_get_time(); + bool success = true; + for (uint32_t seq = 0; seq < uint32_t(count); ++seq) { + std::vector payload; payload.reserve(req); + bench::put32(payload, seq); bench::put32(payload, token); + for (size_t i = 0; i < size_t(req - 8); ++i) payload.push_back(bench::pattern(seq, token, i)); + firstCallbackUs = 0; requestStartedUs = esp_timer_get_time(); waiting = true; + auto channel = activeChannel.load(); + const bool written = channel && channel->write(bench::encode(bench::ping, payload)); + const auto submitUs = uint32_t(esp_timer_get_time() - requestStartedUs); + bench::Frame* raw = nullptr; + const bool received = written && xQueueReceive(replies, &raw, pdMS_TO_TICKS(5000)) == pdTRUE; + waiting = false; + std::unique_ptr reply(raw); + if (!received) { error(written ? "PING timeout" : "PING write failed"); success = false; break; } + const auto& p = reply->payload; + bool valid = reply->type == bench::pingReply && p.size() == size_t(resp) && + bench::u32(p.data()) == seq && bench::u32(p.data() + 4) == token; + for (size_t i = 12; valid && i < p.size(); ++i) valid = p[i] == bench::pattern(seq, token, i - 12); + const auto rtt = uint32_t(reply->receivedUs - requestStartedUs); + const auto turnaround = valid ? bench::u32(p.data() + 8) : 0; + if (!valid || turnaround > rtt) { error("PING length, sequence, pattern or timing mismatch"); success = false; break; } + samples.push_back({seq, rtt, firstCallbackUs.load(), submitUs, turnaround}); + } + const auto elapsed = esp_timer_get_time() - started; + params(); + success = success && callbackFaults.load() == faultsBefore; + std::vector rtts; uint64_t sum = 0; + for (const auto& sample : samples) { rtts.push_back(sample.rtt); sum += sample.rtt; } + std::sort(rtts.begin(), rtts.end()); + auto result = cJSON_CreateObject(); + cJSON_AddBoolToObject(result, "success", success); + number(result, "completed", samples.size()); number(result, "elapsed_us", elapsed); + number(result, "callback_faults", callbackFaults.load() - faultsBefore); + if (!rtts.empty()) { + number(result, "rtt_min_us", rtts.front()); number(result, "rtt_max_us", rtts.back()); + number(result, "rtt_median_us", (double(rtts[(rtts.size() - 1) / 2]) + rtts[rtts.size() / 2]) / 2); + number(result, "rtt_p95_us", rtts[(rtts.size() * 95 + 99) / 100 - 1]); + number(result, "rtt_mean_us", double(sum) / samples.size()); + number(result, "goodput_bytes_per_s", double(samples.size()) * (req + resp - 20) * 1e6 / elapsed); + number(result, "framed_bytes_per_s", double(samples.size()) * (req + resp + 8) * 1e6 / elapsed); + } + number(result, "heap_free", esp_get_free_heap_size()); number(result, "heap_min", esp_get_minimum_free_heap_size()); + emit("run-result", result); + // Serialize samples after timing stops, so the serial link cannot pace the test. + for (const auto& sample : samples) { + auto object = cJSON_CreateObject(); + number(object, "seq", sample.sequence); number(object, "rtt_us", sample.rtt); + number(object, "first_callback_us", sample.first); number(object, "tx_submit_us", sample.submit); + number(object, "peer_turnaround_us", sample.turnaround); + number(object, "adjusted_rtt_us", sample.rtt - sample.turnaround); emit("sample", object); + } + emit("run-end"); if (!success) disconnectClient(); return success ? 0 : 1; +} + +void reflectorTask(void*) { + bench::Config config; + uint32_t expectedSequence = 0; + uint32_t generation = 0, streamToken = 0; + bool streaming = false, rxEnded = false, txConfirmed = false; + for (;;) { + bench::Frame* raw; + if (xQueueReceive(reflectRequests, &raw, portMAX_DELAY) != pdTRUE) continue; + std::unique_ptr request(raw); + struct Busy { Busy() { reflectorBusy = true; } ~Busy() { reflectorBusy = false; } } busy; + auto channel = activeChannel.load(); + if (!channel || !connected.load() || request->generation != connectionGeneration.load()) continue; + if (generation != request->generation) { generation = request->generation; config = {}; streaming = false; } + uint8_t type = bench::abort; + std::vector response; + if (request->type == bench::hello && !request->payload.empty() && request->payload[0] == bench::version) { + config = {}; streaming = false; + const std::string identity = std::string(CONFIG_IDF_TARGET) + " " + BENCH_LIBRARY_COMMIT; + response = {bench::version}; response.insert(response.end(), identity.begin(), identity.end()); type = bench::helloReply; + } else if (request->type == bench::config && !streaming && config.parse(request->payload)) { + expectedSequence = 0; + response = {0}; type = bench::configReply; + } else if (request->type == bench::streamStart && config.pattern >= 3 && !streaming && request->payload.size() == 4) { + streamToken = bench::u32(request->payload.data()); streaming = true; + rxEnded = config.pattern == 4; txConfirmed = config.pattern == 3; + { std::lock_guard lock(streamMutex); + streamReceiver.reset(streamToken, config.pattern != 4 ? config.count : 0, config.request); } + bool ok = channel->write(bench::encode(bench::streamStartReply, request->payload)); + if (ok && config.pattern != 3) ok = sendStream(channel, generation, streamToken, config.count, config.response); + if (!ok) { ++callbackFaults; channel->disconnect(); } + continue; + } else if (request->type == bench::streamEnd && streaming && !rxEnded) { + std::lock_guard lock(streamMutex); + if (streamReceiver.end(request->payload)) { + rxEnded = true; type = bench::streamEndReply; response = streamReceiver.summary.encode(); + } + } else if (request->type == bench::streamEndReply && streaming && !txConfirmed) { + bench::StreamSummary summary; + if (!summary.parse(request->payload) || !summary.matches(streamToken, config.count, config.response)) { + ++callbackFaults; channel->disconnect(); + } else txConfirmed = true; + continue; + } else if (request->type == bench::streamFinish && streaming && rxEnded && txConfirmed && + request->payload.size() == 4 && bench::u32(request->payload.data()) == streamToken) { + streaming = false; clearStream(); type = bench::streamFinishReply; response = request->payload; + } else if (request->type == bench::ping && request->payload.size() == config.request && config.request >= 8) { + const auto seq = bench::u32(request->payload.data()), token = bench::u32(request->payload.data() + 4); + bool valid = seq == expectedSequence && seq < config.count; + for (size_t i = 8; i < request->payload.size(); ++i) + if (request->payload[i] != bench::pattern(seq, token, i - 8)) valid = false; + if (valid) { + bench::put32(response, seq); bench::put32(response, token); + ++expectedSequence; + bench::put32(response, 0); + for (size_t i = 0; i < size_t(config.response - 12); ++i) response.push_back(bench::pattern(seq, token, i)); + type = bench::pingReply; + } + } + auto encoded = bench::encode(type, response); + if (type == bench::pingReply) { + const auto turnaround = uint32_t(esp_timer_get_time() - request->receivedUs); + for (int i = 0; i < 4; ++i) encoded[12 + i] = uint8_t(turnaround >> ((3 - i) * 8)); + } + if (generation == connectionGeneration.load() && !channel->write(encoded)) ++callbackFaults; + } +} + +int command(int argc, char** argv) { + if (!strcmp(argv[0], "run")) { + const int result = run(argc, argv); + heapStatus("after-run"); + return result; + } + if (!strcmp(argv[0], "connect")) return argc == 2 && connectPeer(argv[1]) ? 0 : 1; + if (!strcmp(argv[0], "disconnect")) { + const bool ok = disconnectClient(); + vTaskDelay(pdMS_TO_TICKS(100)); // Let FreeRTOS reclaim deleted tasks before heap sampling. + emit("disconnected"); heapStatus("disconnected"); return ok ? 0 : 1; + } + if (!strcmp(argv[0], "status")) { metadata(); params(); heapStatus(connected.load() ? "connected" : "idle"); return 0; } + if (!strcmp(argv[0], "reboot")) esp_restart(); + if (!strcmp(argv[0], "params")) { + if (argc == 3) { + const double interval = strtod(argv[1], nullptr); + const int phy = atoi(argv[2]); + if (connected.load() || interval < 7.5 || interval > 4000 || (phy != 1 && phy != 2)) { + error("disconnect first; params <1|2>"); return 1; + } + intervalUnits = uint16_t(interval / 1.25); wantedPhy = phy == 2 ? BLE_GAP_LE_PHY_2M : BLE_GAP_LE_PHY_1M; + } + params(); return 0; + } + if (!strcmp(argv[0], "role") && argc == 2 && !strcmp(argv[1], "client")) { + if (!disconnectClient()) return 1; + serverMode = false; reflectorMode = false; emit("client-ready"); return 0; + } + if (!strcmp(argv[0], "await") && argc == 2) { + const long seconds = strtol(argv[1], nullptr, 10); + if (seconds < 1 || seconds > 120 || reflectorMode.load()) return 1; + const auto deadline = esp_timer_get_time() + seconds * 1000000LL; + while (!connected.load() && esp_timer_get_time() < deadline) vTaskDelay(pdMS_TO_TICKS(10)); + if (!connected.load()) { error("waiting for peer timed out"); return 1; } + return greetPeer(serverPsm) ? 0 : 1; + } + if (!strcmp(argv[0], "listen") || + (!strcmp(argv[0], "role") && argc == 2 && !strcmp(argv[1], "server"))) { + if (serverMode.load() && connected.load()) { error("disconnect before changing role"); return 1; } + if (!disconnectClient()) return 1; + serverMode = true; + reflectorMode = strcmp(argv[0], "listen") != 0; + if (!serverStarted) { + auto coc = NimBLEDevice::createL2CAPServer(); + if (!coc->createService(serverPsm, channelMtu, new ChannelCallbacks())) { error("server create failed"); return 1; } + auto server = NimBLEDevice::createServer(); server->setCallbacks(new ServerCallbacks()); + auto service = server->createService(serviceUuid); + auto characteristic = service->createCharacteristic(psmUuid, NIMBLE_PROPERTY::READ); + const uint8_t psm[] = {uint8_t(serverPsm), uint8_t(serverPsm >> 8)}; + characteristic->setValue(psm, sizeof(psm)); + auto advertising = NimBLEDevice::getAdvertising(); + NimBLEAdvertisementData scanResponse; scanResponse.setName("L2CAP-Bench-ESP"); + advertising->setScanResponseData(scanResponse); advertising->addServiceUUID(serviceUuid); + serverStarted = true; + } + auto advertising = NimBLEDevice::getAdvertising(); + advertisingEnabled = true; + if (!advertising->start()) { error("advertising failed"); return 1; } + emit(reflectorMode.load() ? "server-ready" : "listening"); return 0; + } + error("unknown command; role server|client, listen, await , connect , run echo|request , params <1|2>, status, disconnect, reboot"); + return 1; +} +} // namespace + +extern "C" void app_main() { + setvbuf(stdin, nullptr, _IONBF, 0); setvbuf(stdout, nullptr, _IONBF, 0); + usb_serial_jtag_driver_config_t usbConfig{}; usbConfig.rx_buffer_size = 1024; usbConfig.tx_buffer_size = 4096; + ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&usbConfig)); + usb_serial_jtag_vfs_set_rx_line_endings(ESP_LINE_ENDINGS_LF); + usb_serial_jtag_vfs_set_tx_line_endings(ESP_LINE_ENDINGS_LF); + usb_serial_jtag_vfs_use_driver(); fcntl(fileno(stdin), F_SETFL, 0); + replies = xQueueCreate(16, sizeof(bench::Frame*)); reflectRequests = xQueueCreate(16, sizeof(bench::Frame*)); + configASSERT(replies && reflectRequests); + NimBLEDevice::init("L2CAP-Bench-ESP"); NimBLEDevice::setMTU(517); + NimBLEDevice::setCustomGapHandler([](ble_gap_event* event, void*) -> int { + if (event->type == BLE_GAP_EVENT_DATA_LEN_CHG) { + observedTxOctets = event->data_len_chg.max_tx_octets; + observedRxOctets = event->data_len_chg.max_rx_octets; + } else if (event->type == BLE_GAP_EVENT_CONN_UPDATE) { + intervalStatus = event->conn_update.status; + } + return 0; + }); + xTaskCreate(reflectorTask, "reflect", 6144, nullptr, 4, nullptr); + esp_console_config_t console{}; console.max_cmdline_args = 8; console.max_cmdline_length = 256; + ESP_ERROR_CHECK(esp_console_init(&console)); + for (auto name : {"connect", "disconnect", "run", "status", "params", "role", "listen", "await", "reboot"}) { + esp_console_cmd_t cmd{}; cmd.command = name; cmd.help = "L2CAP benchmark command"; cmd.func = command; + ESP_ERROR_CHECK(esp_console_cmd_register(&cmd)); + } + metadata(); emit("ready"); + char line[256]; + while (true) { + if (!fgets(line, sizeof(line), stdin)) { clearerr(stdin); vTaskDelay(1); continue; } + line[strcspn(line, "\r\n")] = '\0'; + if (!line[0]) continue; + int result = 0; + const auto err = esp_console_run(line, &result); + if (err != ESP_OK) error(esp_err_to_name(err)); + auto object = cJSON_CreateObject(); number(object, "code", err == ESP_OK ? result : int(err)); emit("command-end", object); + } +} diff --git a/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults new file mode 100644 index 00000000..a63f08dd --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults @@ -0,0 +1,21 @@ +CONFIG_BT_ENABLED=y +CONFIG_BT_NIMBLE_ENABLED=y +CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM=1 +CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 +CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=517 +CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=24 +CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE=256 +CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=24 +CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE=320 +CONFIG_BT_NIMBLE_ACL_BUF_COUNT=24 +CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT=5 +CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=n +CONFIG_COMPILER_OPTIMIZATION_PERF=y +CONFIG_LOG_DEFAULT_LEVEL_ERROR=y +CONFIG_NIMBLE_CPP_LOG_LEVEL_ERROR=y +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y +CONFIG_ESP_CONSOLE_SECONDARY_NONE=y +CONFIG_FREERTOS_HZ=1000 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=16384 +CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y diff --git a/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s3 b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s3 new file mode 100644 index 00000000..7382da57 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s3 @@ -0,0 +1 @@ +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y diff --git a/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s31 b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s31 new file mode 100644 index 00000000..b34be413 --- /dev/null +++ b/examples/L2CAP/L2CAP_Bench/sdkconfig.defaults.esp32s31 @@ -0,0 +1 @@ +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_320=y diff --git a/src/NimBLEDevice.cpp b/src/NimBLEDevice.cpp index e125aeb9..9504a4ea 100644 --- a/src/NimBLEDevice.cpp +++ b/src/NimBLEDevice.cpp @@ -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(); diff --git a/src/NimBLEL2CAPChannel.cpp b/src/NimBLEL2CAPChannel.cpp index 36b740ad..169eb2f9 100644 --- a/src/NimBLEL2CAPChannel.cpp +++ b/src/NimBLEL2CAPChannel.cpp @@ -8,6 +8,14 @@ # include "NimBLEClient.h" # include "NimBLELog.h" # include "NimBLEUtils.h" +# include "freertos/queue.h" +# include "freertos/task.h" +# ifdef USING_NIMBLE_ARDUINO_HEADERS +# include "nimble/porting/nimble/include/nimble/nimble_port.h" +# else +# include "nimble/nimble_port.h" +# endif +# include # ifdef USING_NIMBLE_ARDUINO_HEADERS # include "nimble/nimble/host/include/host/ble_gap.h" @@ -15,50 +23,238 @@ # include "host/ble_gap.h" # endif -// L2CAP buffer block size -# define L2CAP_BUF_BLOCK_SIZE (250) -# define L2CAP_BUF_SIZE_MTUS_PER_CHANNEL (3) -// Round-up integer division -# define CEIL_DIVIDE(a, b) (((a) + (b) - 1) / (b)) -# define ROUND_DIVIDE(a, b) (((a) + (b) / 2) / (b)) +// Allocate one full SDU per mbuf, matching NimBLE's own CoC examples. +# define L2CAP_SDU_BUFFER_COUNT CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT // Retry -constexpr uint32_t RetryTimeout = 50; -constexpr int RetryCounter = 3; +constexpr uint32_t SendTimeoutMs = 2000; +#ifndef CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH +#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH 16 +#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE 4096 +#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY 5 +#endif + +static void logPoolSanityWarning(const char* tag, uint16_t psm, const ble_l2cap_chan_info& info) { + const uint16_t negotiatedCocMtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu; + const uint16_t effectiveL2capMtu = info.peer_l2cap_mtu < info.our_l2cap_mtu ? info.peer_l2cap_mtu : info.our_l2cap_mtu; + + if (negotiatedCocMtu == 0 || effectiveL2capMtu == 0) { + return; + } + + const uint32_t fragmentsPerSdu = (negotiatedCocMtu + effectiveL2capMtu - 1) / effectiveL2capMtu; + const uint32_t msys1BlockCount = MYNEWT_VAL(MSYS_1_BLOCK_COUNT); + const uint32_t msys1BlockSize = MYNEWT_VAL(MSYS_1_BLOCK_SIZE); + const uint32_t cocMps = MYNEWT_VAL(BLE_L2CAP_COC_MPS); + const uint32_t aclFromLlCount = MYNEWT_VAL(BLE_TRANSPORT_ACL_FROM_LL_COUNT); + const uint32_t localSduBufferCount = L2CAP_SDU_BUFFER_COUNT; + + NIMBLE_LOGI(tag, + "L2CAP COC 0x%04X path geometry: negotiated_coc=%u effective_l2cap=%u fragments_per_sdu=%lu mps=%lu local_sdu_bufs=%lu msys1=%lux%lu acl_from_ll=%lu", + psm, + negotiatedCocMtu, + effectiveL2capMtu, + (unsigned long)fragmentsPerSdu, + (unsigned long)cocMps, + (unsigned long)localSduBufferCount, + (unsigned long)msys1BlockCount, + (unsigned long)msys1BlockSize, + (unsigned long)aclFromLlCount); + + if (effectiveL2capMtu < negotiatedCocMtu && + (fragmentsPerSdu >= msys1BlockCount || fragmentsPerSdu * 2 >= msys1BlockCount || fragmentsPerSdu >= aclFromLlCount)) { + NIMBLE_LOGW(tag, + "L2CAP COC 0x%04X large-SDU risk: negotiated MTU %u requires %lu fragments at L2CAP MTU %u. Current pools (MSYS_1=%lux%lu, ACL_FROM_LL=%lu) may cause ENOMEM/timeouts under load.", + psm, + negotiatedCocMtu, + (unsigned long)fragmentsPerSdu, + effectiveL2capMtu, + (unsigned long)msys1BlockCount, + (unsigned long)msys1BlockSize, + (unsigned long)aclFromLlCount); + } + + if (localSduBufferCount < 3) { + NIMBLE_LOGW(tag, + "L2CAP COC 0x%04X local SDU buffer count is %lu. Very small per-channel SDU pools reduce heap use but can make large MTUs fragile when send/receive work overlaps or callbacks lag.", + psm, + (unsigned long)localSduBufferCount); + } +} + +enum CallbackKind : uint8_t { Connected, Read, Disconnected }; +struct DeferredReadItem { + NimBLEL2CAPChannel* channel; + std::vector* data; + uint32_t generation; + uint8_t kind; +}; +static QueueHandle_t s_l2capDeferredReadQueue = nullptr; +static TaskHandle_t s_l2capDeferredReadTask = nullptr; +static size_t s_l2capChannelCount = 0; +static constexpr size_t LifecycleSlots = 2 * MYNEWT_VAL(BLE_L2CAP_COC_MAX_NUM); + +void deferredReadWorker(void*) { + DeferredReadItem item{}; + while (true) { + if (xQueueReceive(s_l2capDeferredReadQueue, &item, portMAX_DELAY) != pdTRUE) { continue; } + auto* ch = item.channel; + if (item.generation == ch->m_generation.load()) { + if (item.kind == Connected) { + ch->callbacks->onConnect(ch, ch->m_negotiatedMTU.load()); + } else if (item.kind == Read && ch->isConnected()) { + ch->callbacks->onRead(ch, *item.data); + } else if (item.kind == Disconnected) { + // Off the host task: callbacks may join CAN RX or finish a command. + ch->callbacks->onDisconnect(ch); + while (ch->m_pendingHostJobs.load() || ch->m_writers.load()) { vTaskDelay(1); } + ch->m_state.store(NimBLEL2CAPChannel::State::idle); + } + } + delete item.data; + ch->m_pendingDeferredReads.fetch_sub(1); + } +} + +bool ensureDeferredReadWorker() { + // Channel construction belongs to the application's initialization task. + if (s_l2capDeferredReadQueue && s_l2capDeferredReadTask) { return true; } + s_l2capDeferredReadQueue = xQueueCreate(CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH + LifecycleSlots, + sizeof(DeferredReadItem)); + if (!s_l2capDeferredReadQueue) { return false; } + return xTaskCreate(deferredReadWorker, "nimble_l2cap_cb", CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE, + nullptr, CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY, &s_l2capDeferredReadTask) == pdPASS; +} + +void NimBLEL2CAPChannel::dispatchCallback(uint8_t kind, std::vector* data) { + // The host task is the only producer. Reserve two lifecycle slots per + // channel; reconnect is refused until its disconnect callback has finished. + if (kind == Read && uxQueueSpacesAvailable(s_l2capDeferredReadQueue) <= LifecycleSlots) { + delete data; + disconnect(); + return; + } + DeferredReadItem item{this, data, m_generation.load(), kind}; + m_pendingDeferredReads.fetch_add(1); + auto result = xQueueSend(s_l2capDeferredReadQueue, &item, 0); + assert(result == pdTRUE); +} + +struct NimBLEL2CAPChannel::TxCompletion { + SemaphoreHandle_t ready = xSemaphoreCreateBinary(); + std::atomic status{BLE_HS_EUNKNOWN}; + std::atomic completed{false}; + ~TxCompletion() { vSemaphoreDelete(ready); } + void reset() { + // The whole-PDU mutex allows only one waiter. A timeout closes the + // session, and accept waits for the old host job before reusing this. + xSemaphoreTake(ready, 0); + status.store(BLE_HS_EUNKNOWN); + completed.store(false); + } + void finish(int value) { + if (completed.exchange(true)) { return; } + status.store(value); + xSemaphoreGive(ready); + } +}; +void NimBLEL2CAPChannel::transmitOnHost(ble_npl_event* event) { + auto* ch = static_cast(ble_npl_event_get_arg(event)); + auto* buffer = ch->m_txBuffer.exchange(nullptr); + if (!buffer) { return; } + if (!ch->isConnected() || ch->m_txGeneration.load() != ch->m_generation.load() || !ch->channel) { + os_mbuf_free_chain(buffer); + ch->m_completion->finish(BLE_HS_ENOTCONN); + } else { + // Arm before send. An immediate TX_UNSTALLED must not be discarded. + ch->m_activeSend = ch->m_completion.get(); + int rc = ble_l2cap_send(ch->channel, buffer); + // Pinned NimBLE consumes the SDU even on errors from continue_tx. + // Only these two early returns leave ownership with the caller. + if (rc == BLE_HS_EBADDATA || rc == BLE_HS_EBUSY) { os_mbuf_free_chain(buffer); } + if (rc != BLE_HS_ESTALLED) { + ch->m_completion->finish(rc); + ch->m_activeSend = nullptr; + } + } + ch->m_pendingHostJobs.fetch_sub(1); +} + +void NimBLEL2CAPChannel::disconnectOnHost(ble_npl_event* event) { + auto* ch = static_cast(ble_npl_event_get_arg(event)); + if (ch->m_activeSend) { + ch->m_activeSend->finish(BLE_HS_ENOTCONN); + ch->m_activeSend = nullptr; + } + if (ch->channel) { ble_l2cap_disconnect(ch->channel); } + ch->m_pendingHostJobs.fetch_sub(1); +} NimBLEL2CAPChannel::NimBLEL2CAPChannel(uint16_t psm, uint16_t mtu, NimBLEL2CAPChannelCallbacks* callbacks) : psm(psm), mtu(mtu), callbacks(callbacks) { assert(mtu); // fail here, if MTU is too little assert(callbacks); // fail here, if no callbacks are given - assert(setupMemPool()); // fail here, if the memory pool could not be setup + const bool poolReady = setupMemPool(); + assert(poolReady); // fail here, if the memory pool could not be setup + const bool workerReady = ensureDeferredReadWorker(); + assert(workerReady); + ++s_l2capChannelCount; + m_writeMutex = xSemaphoreCreateMutex(); + assert(m_writeMutex); + m_completion = std::make_unique(); + assert(m_completion->ready); + ble_npl_event_init(&m_txEvent, transmitOnHost, this); + ble_npl_event_init(&m_disconnectEvent, disconnectOnHost, this); NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X initialized w/ L2CAP MTU %i", this->psm, this->mtu); }; NimBLEL2CAPChannel::~NimBLEL2CAPChannel() { + while (m_pendingDeferredReads.load() || m_pendingHostJobs.load() || m_writers.load()) { + vTaskDelay(pdMS_TO_TICKS(1)); + } + ble_npl_event_deinit(&m_txEvent); + ble_npl_event_deinit(&m_disconnectEvent); teardownMemPool(); + // All producers and callbacks have drained before the last channel is + // destroyed. Keeping the shared worker would retain its internal stack + // and queue across a full Bluetooth shutdown. + if (--s_l2capChannelCount == 0) { + vTaskDelete(s_l2capDeferredReadTask); + s_l2capDeferredReadTask = nullptr; + vQueueDelete(s_l2capDeferredReadQueue); + s_l2capDeferredReadQueue = nullptr; + } + NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X shutdown and freed.", this->psm); } bool NimBLEL2CAPChannel::setupMemPool() { - const size_t buf_blocks = CEIL_DIVIDE(mtu, L2CAP_BUF_BLOCK_SIZE) * L2CAP_BUF_SIZE_MTUS_PER_CHANNEL; - NIMBLE_LOGD(LOG_TAG, "Computed number of buf_blocks = %d", buf_blocks); + const size_t buf_blocks = L2CAP_SDU_BUFFER_COUNT; + // Pool block size includes both headers. Without this headroom a full + // local-MTU packet would silently need more than one configured buffer. + const size_t blockSize = size_t(mtu) + sizeof(os_mbuf) + sizeof(os_mbuf_pkthdr); + if (blockSize > UINT16_MAX) { return false; } + NIMBLE_LOGD(LOG_TAG, "Allocating %d L2CAP SDU buffers of %d bytes", buf_blocks, mtu); + + memset(&_coc_mempool, 0, sizeof(_coc_mempool)); + memset(&_coc_mbuf_pool, 0, sizeof(_coc_mbuf_pool)); - _coc_memory = malloc(OS_MEMPOOL_SIZE(buf_blocks, L2CAP_BUF_BLOCK_SIZE) * sizeof(os_membuf_t)); + _coc_memory = malloc(OS_MEMPOOL_SIZE(buf_blocks, blockSize) * sizeof(os_membuf_t)); if (_coc_memory == 0) { NIMBLE_LOGE(LOG_TAG, "Can't allocate _coc_memory: %d", errno); return false; } - auto rc = os_mempool_init(&_coc_mempool, buf_blocks, L2CAP_BUF_BLOCK_SIZE, _coc_memory, "appbuf"); + auto rc = os_mempool_init(&_coc_mempool, buf_blocks, blockSize, _coc_memory, "appbuf"); if (rc != 0) { NIMBLE_LOGE(LOG_TAG, "Can't os_mempool_init: %d", rc); return false; } - auto rc2 = os_mbuf_pool_init(&_coc_mbuf_pool, &_coc_mempool, L2CAP_BUF_BLOCK_SIZE, buf_blocks); + auto rc2 = os_mbuf_pool_init(&_coc_mbuf_pool, &_coc_mempool, blockSize, buf_blocks); if (rc2 != 0) { - NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_pool_init: %d", rc); + NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_pool_init: %d", rc2); return false; } @@ -72,87 +268,49 @@ bool NimBLEL2CAPChannel::setupMemPool() { } void NimBLEL2CAPChannel::teardownMemPool() { + if (m_writeMutex) { + vSemaphoreDelete(m_writeMutex); + m_writeMutex = nullptr; + } if (this->callbacks) { delete this->callbacks; + this->callbacks = nullptr; } if (this->receiveBuffer) { free(this->receiveBuffer); + this->receiveBuffer = nullptr; + } + if (_coc_mempool.name) { + os_mempool_unregister(&_coc_mempool); + memset(&_coc_mempool, 0, sizeof(_coc_mempool)); + memset(&_coc_mbuf_pool, 0, sizeof(_coc_mbuf_pool)); } if (_coc_memory) { free(_coc_memory); + _coc_memory = nullptr; } } int NimBLEL2CAPChannel::writeFragment(std::vector::const_iterator begin, std::vector::const_iterator end) { - auto toSend = end - begin; - - if (stalled) { - NIMBLE_LOGD(LOG_TAG, "L2CAP Channel waiting for unstall..."); - NimBLEUtils::TaskData taskData; - m_pTaskData = &taskData; - NimBLEUtils::taskWait(taskData, BLE_NPL_TIME_FOREVER); - m_pTaskData = nullptr; - stalled = false; - NIMBLE_LOGD(LOG_TAG, "L2CAP Channel unstalled!"); + auto* buffer = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0); + if (!buffer) { return BLE_HS_ENOMEM; } + int rc = os_mbuf_append(buffer, &*begin, end - begin); + if (rc) { os_mbuf_free_chain(buffer); return rc; } + auto* completion = m_completion.get(); + completion->reset(); + m_txGeneration.store(m_generation.load()); + m_pendingHostJobs.fetch_add(1); + m_txBuffer.store(buffer); + if (xTaskGetCurrentTaskHandle() == m_hostTask.load()) { + transmitOnHost(&m_txEvent); + // A host callback must never wait for host progress. + if (xSemaphoreTake(completion->ready, 0) != pdTRUE) { return BLE_HS_ETIMEOUT; } + } else { + // At most one TX event and one disconnect event are outstanding per channel. + ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &m_txEvent); + if (xSemaphoreTake(completion->ready, pdMS_TO_TICKS(SendTimeoutMs)) != pdTRUE) { return BLE_HS_ETIMEOUT; } } - - struct ble_l2cap_chan_info info; - ble_l2cap_get_chan_info(channel, &info); - // Take the minimum of our and peer MTU - auto mtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu; - - if (toSend > mtu) { - return -BLE_HS_EBADDATA; - } - - auto retries = RetryCounter; - - while (retries--) { - auto txd = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0); - if (!txd) { - NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_get_pkthdr."); - return -BLE_HS_ENOMEM; - } - auto append = os_mbuf_append(txd, &(*begin), toSend); - if (append != 0) { - NIMBLE_LOGE(LOG_TAG, "Can't os_mbuf_append: %d", append); - return append; - } - - auto res = ble_l2cap_send(channel, txd); - switch (res) { - case 0: - NIMBLE_LOGD(LOG_TAG, "L2CAP COC 0x%04X sent %d bytes.", this->psm, toSend); - return 0; - - case BLE_HS_ESTALLED: - stalled = true; - NIMBLE_LOGD(LOG_TAG, "L2CAP COC 0x%04X sent %d bytes.", this->psm, toSend); - NIMBLE_LOGW(LOG_TAG, - "ble_l2cap_send returned BLE_HS_ESTALLED. Next send will wait for unstalled event..."); - return 0; - - case BLE_HS_ENOMEM: - case BLE_HS_EAGAIN: - /* ble_l2cap_send already consumed and freed txd on these errors */ - NIMBLE_LOGD(LOG_TAG, "ble_l2cap_send returned %d (consumed buffer). Retrying shortly...", res); - ble_npl_time_delay(ble_npl_time_ms_to_ticks32(RetryTimeout)); - continue; - - case BLE_HS_EBUSY: - /* Channel busy; txd not consumed */ - NIMBLE_LOGD(LOG_TAG, "ble_l2cap_send returned %d (busy). Retrying shortly...", res); - os_mbuf_free_chain(txd); - ble_npl_time_delay(ble_npl_time_ms_to_ticks32(RetryTimeout)); - continue; - - default: - NIMBLE_LOGE(LOG_TAG, "ble_l2cap_send failed: %d", res); - return res; - } - } - NIMBLE_LOGE(LOG_TAG, "Retries exhausted, dropping %d bytes to send.", toSend); - return -BLE_HS_EREJECT; + return completion->status.load(); } # if MYNEWT_VAL(BLE_ROLE_CENTRAL) @@ -172,89 +330,136 @@ NimBLEL2CAPChannel* NimBLEL2CAPChannel::connect(NimBLEClient* cli auto sdu_rx = os_mbuf_get_pkthdr(&channel->_coc_mbuf_pool, 0); if (!sdu_rx) { NIMBLE_LOGE(LOG_TAG, "Can't allocate SDU buffer: %d, %s", errno, strerror(errno)); + delete channel; return nullptr; } auto rc = ble_l2cap_connect(client->getConnHandle(), psm, mtu, sdu_rx, NimBLEL2CAPChannel::handleL2capEvent, channel); if (rc != 0) { NIMBLE_LOGE(LOG_TAG, "ble_l2cap_connect failed: %d", rc); + os_mbuf_free_chain(sdu_rx); + delete channel; + return nullptr; } return channel; } # endif // MYNEWT_VAL(BLE_ROLE_CENTRAL) bool NimBLEL2CAPChannel::write(const std::vector& bytes) { - if (!this->channel) { - NIMBLE_LOGW(LOG_TAG, "L2CAP Channel not open"); - return false; - } - - struct ble_l2cap_chan_info info; - ble_l2cap_get_chan_info(channel, &info); - auto mtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu; - + const auto generation = m_generation.load(); + m_writers.fetch_add(1); + struct WriterExit { std::atomic& count; ~WriterExit() { count.fetch_sub(1); } } exit{m_writers}; + if (!isConnected()) { return false; } + auto wait = xTaskGetCurrentTaskHandle() == m_hostTask.load() ? 0 : pdMS_TO_TICKS(SendTimeoutMs); + if (xSemaphoreTake(m_writeMutex, wait) != pdTRUE) { disconnect(); return false; } + struct Unlock { SemaphoreHandle_t mutex; ~Unlock() { xSemaphoreGive(mutex); } } unlock{m_writeMutex}; + if (!isConnected() || generation != m_generation.load()) { return false; } + const auto width = m_txSduWidth.load(); + if (!width) { disconnect(); return false; } auto start = bytes.begin(); while (start != bytes.end()) { - auto end = start + mtu < bytes.end() ? start + mtu : bytes.end(); - if (writeFragment(start, end) < 0) { - return false; - } + if (!isConnected()) { return false; } + const auto length = std::min(width, bytes.end() - start); + auto end = start + length; + if (writeFragment(start, end) != 0) { disconnect(); return false; } start = end; } return true; } bool NimBLEL2CAPChannel::disconnect() { - if (!this->channel) { - NIMBLE_LOGW(LOG_TAG, "L2CAP Channel not open"); - return false; - } - - int rc = ble_l2cap_disconnect(this->channel); - if (rc != 0 && rc != BLE_HS_ENOTCONN && rc != BLE_HS_EALREADY) { - NIMBLE_LOGE(LOG_TAG, "ble_l2cap_disconnect failed: rc=%d %s", rc, NimBLEUtils::returnCodeToString(rc)); - return false; - } - + auto state = m_state.load(); + do { + if (state == State::idle || state == State::closing) { return false; } + } while (!m_state.compare_exchange_weak(state, State::closing)); + m_pendingHostJobs.fetch_add(1); + if (xTaskGetCurrentTaskHandle() == m_hostTask.load()) { disconnectOnHost(&m_disconnectEvent); } + else { ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &m_disconnectEvent); } return true; } uint16_t NimBLEL2CAPChannel::getConnHandle() const { - if (!this->channel) { - return BLE_HS_CONN_HANDLE_NONE; - } - return ble_l2cap_get_conn_handle(this->channel); + return m_connHandle.load(); } // private int NimBLEL2CAPChannel::handleConnectionEvent(struct ble_l2cap_event* event) { + m_hostTask = xTaskGetCurrentTaskHandle(); channel = event->connect.chan; + m_generation.fetch_add(1); + m_connHandle.store(ble_l2cap_get_conn_handle(channel)); + m_state.store(State::open); struct ble_l2cap_chan_info info; - ble_l2cap_get_chan_info(channel, &info); + int rc = ble_l2cap_get_chan_info(channel, &info); + if (rc != 0) { + NIMBLE_LOGE(LOG_TAG, "L2CAP COC 0x%04X connected but ble_l2cap_get_chan_info failed: %d", psm, rc); + disconnect(); + return rc; + } NIMBLE_LOGI(LOG_TAG, - "L2CAP COC 0x%04X connected. Local MTU = %d [%d], remote MTU = %d [%d].", + "L2CAP COC 0x%04X connected. scid=0x%04X dcid=0x%04X local_l2cap=%d local_coc=%d peer_l2cap=%d peer_coc=%d.", psm, - info.our_coc_mtu, + info.scid, + info.dcid, info.our_l2cap_mtu, - info.peer_coc_mtu, - info.peer_l2cap_mtu); - if (info.our_coc_mtu > info.peer_coc_mtu) { + info.our_coc_mtu, + info.peer_l2cap_mtu, + info.peer_coc_mtu); + logPoolSanityWarning(LOG_TAG, psm, info); + if (info.our_coc_mtu > 0 && info.peer_coc_mtu > 0 && info.our_coc_mtu > info.peer_coc_mtu) { NIMBLE_LOGW(LOG_TAG, "L2CAP COC 0x%04X connected, but local MTU is bigger than remote MTU.", psm); } - auto mtu = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu; - callbacks->onConnect(this, mtu); + + uint16_t negotiatedMTU = 0; + if (info.our_coc_mtu > 0 && info.peer_coc_mtu > 0) { + negotiatedMTU = info.peer_coc_mtu < info.our_coc_mtu ? info.peer_coc_mtu : info.our_coc_mtu; + } else if (info.our_l2cap_mtu > 0 && info.peer_l2cap_mtu > 0) { + negotiatedMTU = info.peer_l2cap_mtu < info.our_l2cap_mtu ? info.peer_l2cap_mtu : info.our_l2cap_mtu; + NIMBLE_LOGW(LOG_TAG, + "L2CAP COC 0x%04X connected with zero CoC MTU info; falling back to L2CAP MTU %u for callback.", + psm, + negotiatedMTU); + } else { + negotiatedMTU = mtu; + NIMBLE_LOGW(LOG_TAG, + "L2CAP COC 0x%04X connected with no peer MTU info; falling back to configured MTU %u for callback.", + psm, + negotiatedMTU); + } + + m_negotiatedMTU.store(negotiatedMTU); + uint16_t txSduWidth = negotiatedMTU; + m_txSduWidth.store(txSduWidth); +#if CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS + dispatchCallback(Connected); +#else + callbacks->onConnect(this, negotiatedMTU); +#endif return 0; } int NimBLEL2CAPChannel::handleAcceptEvent(struct ble_l2cap_event* event) { NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X accept.", psm); - if (!callbacks->shouldAcceptConnection(this)) { + m_hostTask = xTaskGetCurrentTaskHandle(); + if (m_state.load() != State::idle || m_pendingDeferredReads.load() || m_pendingHostJobs.load() || m_writers.load() || + !callbacks->shouldAcceptConnection(this)) { NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X refused by delegate.", psm); return -1; } struct os_mbuf* sdu_rx = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0); - assert(sdu_rx != NULL); - ble_l2cap_recv_ready(event->accept.chan, sdu_rx); + if (!sdu_rx) { + NIMBLE_LOGE(LOG_TAG, "L2CAP COC 0x%04X could not allocate receive SDU.", psm); + return BLE_HS_ENOMEM; + } + + int rc = ble_l2cap_recv_ready(event->accept.chan, sdu_rx); + if (rc != 0) { + NIMBLE_LOGE(LOG_TAG, "L2CAP COC 0x%04X ble_l2cap_recv_ready failed during accept: %d", psm, rc); + os_mbuf_free_chain(sdu_rx); + return rc; + } + + m_state.store(State::accepted); return 0; } @@ -276,7 +481,6 @@ int NimBLEL2CAPChannel::handleDataReceivedEvent(struct ble_l2cap_event* event) { assert(res == 0); std::vector incomingData(receiveBuffer, receiveBuffer + rx_len); - callbacks->onRead(this, incomingData); struct os_mbuf* next = os_mbuf_get_pkthdr(&_coc_mbuf_pool, 0); assert(next != NULL); @@ -284,22 +488,35 @@ int NimBLEL2CAPChannel::handleDataReceivedEvent(struct ble_l2cap_event* event) { res = ble_l2cap_recv_ready(channel, next); assert(res == 0); +#if CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS + dispatchCallback(Read, new std::vector(std::move(incomingData))); +#else + if (isConnected()) { callbacks->onRead(this, incomingData); } +#endif + return 0; } int NimBLEL2CAPChannel::handleTxUnstalledEvent(struct ble_l2cap_event* event) { - if (m_pTaskData != nullptr) { - NimBLEUtils::taskRelease(*m_pTaskData, event->tx_unstalled.status); + if (event->tx_unstalled.chan == channel && m_activeSend) { + m_activeSend->finish(event->tx_unstalled.status); + m_activeSend = nullptr; } - - NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X transmit unstalled.", psm); return 0; } int NimBLEL2CAPChannel::handleDisconnectionEvent(struct ble_l2cap_event* event) { - NIMBLE_LOGI(LOG_TAG, "L2CAP COC 0x%04X disconnected.", psm); - channel = NULL; - callbacks->onDisconnect(this); + if (!channel || event->disconnect.chan != channel) { return 0; } + // NimBLE invokes this while holding its host lock. Never wait or invoke + // application cleanup here; that cleanup can join a producer using NimBLE. + m_state.store(State::closing); + m_connHandle.store(BLE_HS_CONN_HANDLE_NONE); + channel = nullptr; + if (m_activeSend) { + m_activeSend->finish(BLE_HS_ENOTCONN); + m_activeSend = nullptr; + } + dispatchCallback(Disconnected); return 0; } diff --git a/src/NimBLEL2CAPChannel.h b/src/NimBLEL2CAPChannel.h index 0e196bdb..a2f970f5 100644 --- a/src/NimBLEL2CAPChannel.h +++ b/src/NimBLEL2CAPChannel.h @@ -11,9 +11,11 @@ # include "inttypes.h" # ifdef USING_NIMBLE_ARDUINO_HEADERS # include "nimble/nimble/host/include/host/ble_l2cap.h" +# include "nimble/nimble/host/include/host/ble_hs.h" # include "nimble/porting/nimble/include/os/os_mbuf.h" # else # include "host/ble_l2cap.h" +# include "host/ble_hs.h" # include "os/os_mbuf.h" # endif @@ -26,6 +28,15 @@ # include # include +# include +# ifdef USING_NIMBLE_ARDUINO_HEADERS +# include "nimble/nimble/include/nimble/nimble_npl.h" +# else +# include "nimble/nimble_npl.h" +# endif +# include "freertos/FreeRTOS.h" +# include "freertos/semphr.h" +# include "freertos/task.h" class NimBLEClient; class NimBLEL2CAPChannelCallbacks; @@ -44,7 +55,9 @@ class NimBLEL2CAPChannel { /// @param[in] mtu The MTU to use. Note that this is the local MTU. Upon opening the channel, /// the final MTU will be negotiated to be the minimum of local and remote. /// @param[in] callbacks The callbacks to use. NOTE that these callbacks are called from the - /// context of the NimBLE bluetooth task (`nimble_host`) and MUST be handled as fast as possible. + /// context of the NimBLE bluetooth task (`nimble_host`) unless deferred callbacks are + /// enabled. In that mode onConnect()/onRead() run on a shared worker. + /// onDisconnect() always runs on that worker, serialized after deferred reads. /// @return True if the channel was opened successfully, false otherwise. static NimBLEL2CAPChannel* connect(NimBLEClient* client, uint16_t psm, uint16_t mtu, NimBLEL2CAPChannelCallbacks* callbacks); @@ -54,7 +67,9 @@ class NimBLEL2CAPChannel { /// @return true on success, after the data has been sent. /// @return false, if the data can't be sent. /// - /// NOTE: This function will block until the data has been sent or an error occurred. + /// Whole writes are serialized. Credit waits are bounded; an incomplete write + /// aborts the connection. Call from a worker task for blocking operation. + /// Owners must disconnect and let callbacks finish before deleting a channel. bool write(const std::vector& bytes); /// @brief Disconnect this L2CAP channel. @@ -64,13 +79,18 @@ class NimBLEL2CAPChannel { /// @brief Get the connection handle associated with this channel. /// @return Connection handle, or BLE_HS_CONN_HANDLE_NONE if not connected. uint16_t getConnHandle() const; - /// @return True, if the channel is connected. False, otherwise. - bool isConnected() const { return !!channel; } + bool isConnected() const { return m_state.load() == State::open; } + // Stop accepting new connections before using this to drain a channel. + bool isIdle() const { + return m_state.load() == State::idle && !m_pendingDeferredReads.load() && + !m_pendingHostJobs.load() && !m_writers.load(); + } + + ~NimBLEL2CAPChannel(); protected: NimBLEL2CAPChannel(uint16_t psm, uint16_t mtu, NimBLEL2CAPChannelCallbacks* callbacks); - ~NimBLEL2CAPChannel(); int handleConnectionEvent(struct ble_l2cap_event* event); int handleAcceptEvent(struct ble_l2cap_event* event); @@ -80,6 +100,7 @@ class NimBLEL2CAPChannel { private: friend class NimBLEL2CAPServer; + friend void deferredReadWorker(void*); static constexpr const char* LOG_TAG = "NimBLEL2CAPChannel"; const uint16_t psm; // PSM of the channel @@ -93,9 +114,30 @@ class NimBLEL2CAPChannel { struct os_mempool _coc_mempool; struct os_mbuf_pool _coc_mbuf_pool; - // Runtime handling - std::atomic stalled{false}; - NimBLEUtils::TaskData* m_pTaskData{nullptr}; + // Only host events access the native channel and active SDU. Never hold a + // wrapper lock while entering NimBLE: disconnect callbacks hold ble_hs_lock. + enum class State { idle, accepted, open, closing }; + std::atomic m_state{State::idle}; + std::atomic m_generation{0}; + std::atomic m_negotiatedMTU{0}; + std::atomic m_txSduWidth{0}; + std::atomic m_connHandle{BLE_HS_CONN_HANDLE_NONE}; + std::atomic m_pendingDeferredReads{0}; // includes running callbacks + std::atomic m_pendingHostJobs{0}; + std::atomic m_writers{0}; + SemaphoreHandle_t m_writeMutex{nullptr}; + struct TxCompletion; + std::unique_ptr m_completion; + std::atomic m_txBuffer{nullptr}; + std::atomic m_txGeneration{0}; + TxCompletion* m_activeSend{nullptr}; // host task only + ble_npl_event m_txEvent{}; + ble_npl_event m_disconnectEvent{}; + std::atomic m_hostTask{nullptr}; + static void transmitOnHost(ble_npl_event* event); + static void disconnectOnHost(ble_npl_event* event); + void dispatchCallback(uint8_t kind, std::vector* data = nullptr); + // Allocate / deallocate NimBLE memory pool bool setupMemPool(); diff --git a/src/NimBLEServer.cpp b/src/NimBLEServer.cpp index 660a0313..0e39a5d8 100644 --- a/src/NimBLEServer.cpp +++ b/src/NimBLEServer.cpp @@ -55,7 +55,7 @@ NimBLEServer::NimBLEServer() m_svcChanged{false}, m_deleteCallbacks{false}, m_registerAppServicesFirst{false}, -# if !MYNEWT_VAL(BLE_EXT_ADV) +# if !MYNEWT_VAL(BLE_EXT_ADV) && MYNEWT_VAL(BLE_ROLE_BROADCASTER) m_advertiseOnDisconnect{false}, # endif m_pServerCallbacks{&defaultCallbacks}, @@ -363,7 +363,11 @@ bool NimBLEServer::disconnect(const NimBLEConnInfo& connInfo, uint8_t reason) co * @param [in] enable true == advertise, false == don't advertise. */ void NimBLEServer::advertiseOnDisconnect(bool enable) { +# if MYNEWT_VAL(BLE_ROLE_BROADCASTER) m_advertiseOnDisconnect = enable; +# else + (void)enable; +# endif } // advertiseOnDisconnect # endif diff --git a/tests/l2cap/BenchProtocolTests.cpp b/tests/l2cap/BenchProtocolTests.cpp new file mode 100644 index 00000000..ccbba2ea --- /dev/null +++ b/tests/l2cap/BenchProtocolTests.cpp @@ -0,0 +1,47 @@ +#include "../../examples/L2CAP/L2CAP_Bench/main/bench_protocol.hpp" +#include + +int main() { + bench::Parser parser; + std::vector frames; + auto emit = [&](bench::Frame frame) { frames.push_back(std::move(frame)); }; + const auto hello = bench::encode(bench::hello, {1, 'S', '3'}); + // A byte stream can split every header and payload byte. + for (auto byte : hello) assert(parser.feed({byte}, emit)); + assert(frames.size() == 1 && frames[0].payload == std::vector({1, 'S', '3'})); + auto combined = hello; combined.insert(combined.end(), hello.begin(), hello.end()); + assert(parser.feed(combined, emit)); assert(frames.size() == 3); + parser.reset(); assert(!parser.feed({0xB7, 3, 0x13, 0x85}, emit)); + assert(!parser.feed(hello, emit)); // A malformed stream stays rejected until reconnect/reset. + parser.reset(); assert(parser.feed(hello, emit)); + bench::Config config; + assert(config.parse({2, 0, 8, 0x10, 0, 0, 0, 0, 64})); + assert(config.request == 8 && config.response == 4096 && config.count == 64); + assert(!config.parse({1, 0, 8, 0x10, 0, 0, 0, 0, 64})); + std::vector numbers; bench::put32(numbers, 0xAABBCCDD); + assert(numbers == std::vector({0xAA, 0xBB, 0xCC, 0xDD})); + assert(bench::u32(numbers.data()) == 0xAABBCCDD); + assert(bench::pattern(0x01020304, 0xAABBCCDD, 0) == 0xE1); + for (uint8_t mode : {3, 4, 5}) { + bench::Config streaming{mode, 4096, 4096, 64}; + assert(config.parse(streaming.encode())); + } + bench::StreamReceiver receiver; receiver.reset(0xAABBCCDD, 2, 64); + auto p0 = bench::streamPayload(0, 0xAABBCCDD, 64); + auto p1 = bench::streamPayload(1, 0xAABBCCDD, 64); + assert(receiver.receive(p0, 0x100000001)); + assert(receiver.receive(p1, 0x100000002)); + std::vector end; bench::put32(end, 0xAABBCCDD); bench::put32(end, 2); + assert(receiver.end(end)); + bench::StreamSummary summary; assert(summary.parse(receiver.summary.encode())); + assert(summary.matches(0xAABBCCDD, 2, 64) && summary.firstUs == 0x100000001); + assert(!receiver.receive(p1, 0x100000003)); // Data after END cannot pass. + for (const auto& bad : {p0, bench::streamPayload(1, 42, 64), bench::streamPayload(1, 0xAABBCCDD, 65)}) { + receiver.reset(0xAABBCCDD, 2, 64); assert(receiver.receive(p0, 100)); + assert(!receiver.receive(bad, 200)); assert(!receiver.end(end)); + } + receiver.reset(0xAABBCCDD, 2, 64); assert(receiver.receive(p1, 100) == false); // Gap. + receiver.reset(0xAABBCCDD, 2, 64); p0.back() ^= 1; + assert(!receiver.receive(p0, 100)); + assert(!summary.parse({1, 2, 3})); +} diff --git a/tests/l2cap/CMakeLists.txt b/tests/l2cap/CMakeLists.txt new file mode 100644 index 00000000..7b92f2fb --- /dev/null +++ b/tests/l2cap/CMakeLists.txt @@ -0,0 +1,32 @@ +cmake_minimum_required(VERSION 3.20) +project(L2CAPTests LANGUAGES CXX) +enable_testing() +find_package(Threads REQUIRED) +add_executable(l2cap_tests L2CAPTests.cpp ../../src/NimBLEL2CAPChannel.cpp) +target_compile_features(l2cap_tests PRIVATE cxx_std_17) +target_include_directories(l2cap_tests PRIVATE stubs ../../src) +target_compile_options(l2cap_tests PRIVATE -include ${CMAKE_CURRENT_SOURCE_DIR}/stubs/Host.h) +target_link_libraries(l2cap_tests PRIVATE Threads::Threads) +add_test(NAME l2cap_errors COMMAND l2cap_tests errors) +add_test(NAME l2cap_early_completion COMMAND l2cap_tests early) +add_test(NAME l2cap_disconnect COMMAND l2cap_tests disconnect) +set_tests_properties(l2cap_errors l2cap_early_completion l2cap_disconnect PROPERTIES TIMEOUT 10) + +foreach(case serialize timeout queued_timeout callbacks reconnect ownership lifecycle partial append success pool) + add_test(NAME l2cap_${case} COMMAND l2cap_tests ${case}) + set_tests_properties(l2cap_${case} PROPERTIES TIMEOUT 10) +endforeach() + +add_executable(l2cap_inline_tests L2CAPTests.cpp ../../src/NimBLEL2CAPChannel.cpp) +target_compile_features(l2cap_inline_tests PRIVATE cxx_std_17) +target_compile_definitions(l2cap_inline_tests PRIVATE CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS=0) +target_include_directories(l2cap_inline_tests PRIVATE stubs ../../src) +target_compile_options(l2cap_inline_tests PRIVATE -include ${CMAKE_CURRENT_SOURCE_DIR}/stubs/Host.h) +target_link_libraries(l2cap_inline_tests PRIVATE Threads::Threads) +add_test(NAME l2cap_inline_errors COMMAND l2cap_inline_tests errors) + +add_test(NAME l2cap_inline_lifecycle COMMAND l2cap_inline_tests lifecycle) + +add_executable(l2cap_bench_protocol_tests BenchProtocolTests.cpp) +target_compile_features(l2cap_bench_protocol_tests PRIVATE cxx_std_17) +add_test(NAME l2cap_bench_protocol COMMAND l2cap_bench_protocol_tests) diff --git a/tests/l2cap/L2CAPTests.cpp b/tests/l2cap/L2CAPTests.cpp new file mode 100644 index 00000000..b51c748a --- /dev/null +++ b/tests/l2cap/L2CAPTests.cpp @@ -0,0 +1,298 @@ +#include "NimBLEL2CAPChannel.h" +#include +#include +#include +using namespace std::chrono_literals; +static void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +static void onHost(std::function fn) { + struct Call { + ble_npl_event event; + std::function fn; + std::shared_ptr> done; + }; + auto done = std::make_shared>(); + auto future = done->get_future(); + auto* call = new Call{{}, std::move(fn), done}; + ble_npl_event_init( + &call->event, + [](ble_npl_event* e) { + std::unique_ptr c(static_cast(ble_npl_event_get_arg(e))); + c->fn(); + c->done->set_value(); + }, + call); + ble_npl_eventq_put(nimble_port_get_dflt_eventq(), &call->event); + future.get(); +} +struct Channel : NimBLEL2CAPChannel { + ble_l2cap_chan native; + Channel(NimBLEL2CAPChannelCallbacks* cb = new NimBLEL2CAPChannelCallbacks) : NimBLEL2CAPChannel(129, 64, cb) { + onHost([&] { + ble_l2cap_event e{}; + e.connect.chan = &native; + handleConnectionEvent(&e); + }); + } + ~Channel() { disconnected(); } + bool accept() { + int result = -1; + onHost([&] { + ble_l2cap_event e{}; + e.accept.chan = &native; + result = handleAcceptEvent(&e); + }); + return result == 0; + } + void connected() { + onHost([&] { + ble_l2cap_event e{}; + e.connect.chan = &native; + handleConnectionEvent(&e); + }); + } + void unstalled(int status = 0) { + ble_l2cap_event e{}; + e.tx_unstalled.status = status; + e.tx_unstalled.chan = &native; + handleTxUnstalledEvent(&e); + } + void disconnected() { + onHost([&] { + ble_l2cap_event e{}; + e.disconnect.chan = &native; + handleDisconnectionEvent(&e); + }); + } + void receive(std::vector bytes) { + onHost([&] { + ble_l2cap_event e{}; + e.receive.sdu_rx = new os_mbuf{std::move(bytes)}; + handleDataReceivedEvent(&e); + }); + } +}; +int main(int argc, char** argv) { + try { + const std::string name = argc > 1 ? argv[1] : "errors"; + if (name == "pool") { + Channel c; + require(hostPayloadCapacity >= 64, "one configured buffer cannot hold a full local-MTU SDU"); + } else if (name == "lifecycle") { + nimble_port_get_dflt_eventq(); + const int baselineQueues = hostQueueCount.load(); + for (int cycle = 0; cycle < 30; ++cycle) { + { + Channel first; + { + Channel second; + require(hostTaskCount == 1 && hostQueueCount == baselineQueues + 1, "channels did not share worker"); + } + require(hostTaskCount == 1 && hostQueueCount == baselineQueues + 1, "worker stopped before last channel"); + } + require(hostTaskCount == 0 && hostQueueCount == baselineQueues, "worker or queue leaked after shutdown"); + } + } else if (name == "errors") { + for (auto error : {BLE_HS_ENOMEM, BLE_HS_EAGAIN, BLE_HS_ENOTCONN, BLE_HS_EINVAL}) { + Channel c; + int sends = 0; + hostDisconnects = 0; + hostSend = [&](os_mbuf* b) { + ++sends; + delete b; + return error; + }; + const bool success = c.write(std::vector(20, 0x55)); + require(!success, "positive send error incorrectly reported as success"); + require(sends == 1, "continued after failed PDU fragment"); + onHost([] {}); + require(hostDisconnects == 1, "failed PDU did not abort stream"); + } + } else if (name == "partial") { + Channel c; + int sends = 0; + std::vector delivered; + hostDisconnects = 0; + hostSend = [&](os_mbuf* b) { + ++sends; + const size_t size = sends == 1 ? b->bytes.size() : 2; + delivered.insert(delivered.end(), b->bytes.begin(), b->bytes.begin() + size); + delete b; + return sends == 1 ? 0 : BLE_HS_ENOMEM; + }; + require(!c.write(std::vector(24, 0x55)), "partial stream reported success"); + onHost([] {}); + require(sends == 2 && delivered.size() == 10 && hostDisconnects == 1, + "partial SDU was replayed or incomplete stream not aborted"); + require(liveMbufs == 0, "partial send leaked mbuf"); + } else if (name == "append") { + Channel c; + std::atomic sends{0}; + hostDisconnects = 0; + appendError = BLE_HS_ENOMEM; + hostSend = [&](os_mbuf* b) { ++sends; delete b; return 0; }; + require(!c.write({1, 2, 3}), "append error reported success"); + onHost([] {}); + require(sends == 0 && liveMbufs == 0 && hostDisconnects == 1, + "append error leaked mbuf or submitted data"); + } else if (name == "success") { + Channel c; + int sends = 0; + std::vector input, received; + for (int i = 0; i < 20; ++i) input.push_back(uint8_t(i)); + hostSend = [&](os_mbuf* b) { + ++sends; + received.insert(received.end(), b->bytes.begin(), b->bytes.end()); + delete b; + return 0; + }; + require(c.write(input) && sends == 3 && received == input && liveMbufs == 0, + "valid fragmented write failed"); + } else if (name == "early") { + Channel c; + hostSend = [&](os_mbuf* b) { + delete b; + c.unstalled(); + return BLE_HS_ESTALLED; + }; + const auto start = std::chrono::steady_clock::now(); + bool result = c.write({1, 2, 3}); + require(result, "early TX_UNSTALLED completion lost"); + require(std::chrono::steady_clock::now() - start < 500ms, "early completion was drained before wait"); + } else if (name == "disconnect") { + Channel c; + std::promise submitted; + hostSend = [&](os_mbuf* b) { + delete b; + submitted.set_value(); + return BLE_HS_ESTALLED; + }; + auto writer = std::async(std::launch::async, [&] { return c.write({1, 2, 3}); }); + submitted.get_future().wait(); + c.disconnected(); + bool woke = writer.wait_for(300ms) == std::future_status::ready; + if (!woke) + onHost([&] { c.unstalled(BLE_HS_ENOTCONN); }); // let old implementation exit before reporting failure + const bool success = writer.get(); + require(woke, "disconnect did not wake blocked sender"); + require(!success, "disconnected send reported success"); + } else if (name == "serialize") { + Channel c; + std::mutex mutex; + std::vector sent; + hostSend = [&](os_mbuf* b) { + std::lock_guard l(mutex); + sent.push_back(b->bytes.front()); + delete b; + vTaskDelay(2); + return 0; + }; + auto a = std::async(std::launch::async, [&] { return c.write(std::vector(80, 1)); }); + auto b = std::async(std::launch::async, [&] { return c.write(std::vector(80, 2)); }); + require(!c.accept(), "accepted another connection during active writes"); + require(a.get() && b.get(), "concurrent write failed"); + require(sent.size() == 20, "wrong fragment count"); + int changes = 0; + for (size_t i = 1; i < sent.size(); ++i) changes += sent[i] != sent[i - 1]; + require(changes == 1, "whole-PDU fragments interleaved"); + } else if (name == "timeout") { + Channel c; + std::atomic sends{0}; + hostDisconnects = 0; + hostSend = [&](os_mbuf* b) { + ++sends; + delete b; + return BLE_HS_ESTALLED; + }; + auto start = std::chrono::steady_clock::now(); + require(!c.write(std::vector(20, 1)), "stalled timeout reported success"); + require(std::chrono::steady_clock::now() - start < 3s, "send timeout unbounded"); + onHost([] {}); + require(hostDisconnects == 1 && sends == 1, "timeout did not abort remaining fragments"); + } else if (name == "queued_timeout") { + Channel c; + std::promise entered, release; + auto gate = release.get_future().share(); + std::atomic sends{0}; + hostDisconnects = 0; + hostSend = [&](os_mbuf* b) { + ++sends; + delete b; + return 0; + }; + auto blocked = std::async(std::launch::async, [&] { + onHost([&] { + entered.set_value(); + gate.wait(); + }); + }); + entered.get_future().wait(); + bool success = c.write(std::vector(20, 1)); + release.set_value(); + blocked.get(); + onHost([] {}); + require(!success && sends == 0, "queued SDU was sent after timeout closed session"); + require(hostDisconnects == 1, "queued timeout did not request disconnect"); + } else if (name == "reconnect") { + Channel c; + require(!c.accept(), "accepted a second connection while open"); + hostSend = [&](os_mbuf* b) { + delete b; + return BLE_HS_ESTALLED; + }; + require(!c.write({1}), "stalled write did not time out"); + require(!c.accept(), "accepted before native disconnect and cleanup"); + c.disconnected(); + bool accepted = false; + for (int i = 0; i < 200 && !accepted; ++i) { + accepted = c.accept(); + if (!accepted) vTaskDelay(1); + } + require(accepted, "cleanup did not allow reconnect"); + require(!c.accept(), "accepted twice before connected event"); + c.connected(); + hostSend = [&](os_mbuf* b) { + delete b; + return 0; + }; + require(c.write(std::vector(80, 2)), "old timeout contaminated new connection"); + } else if (name == "ownership") { + for (auto error : {BLE_HS_EBADDATA, BLE_HS_EBUSY}) { + Channel c; + hostSend = [=](os_mbuf*) { return error; }; // Native early errors retain caller ownership. + require(!c.write({1}), "early native error reported success"); + } + } else if (name == "callbacks") { + struct Callbacks : NimBLEL2CAPChannelCallbacks { + std::promise entered, release; + std::shared_future gate = release.get_future().share(); + std::atomic reads{0}, closes{0}; + void onRead(NimBLEL2CAPChannel*, std::vector&) override { + ++reads; + entered.set_value(); + gate.wait(); + } + void onDisconnect(NimBLEL2CAPChannel*) override { ++closes; } + }; + auto* callbacks = new Callbacks; + Channel c(callbacks); + hostDisconnects = 0; + c.receive({1}); + callbacks->entered.get_future().wait(); + for (int i = 0; i < 8; ++i) c.receive({2}); + require(hostDisconnects == 1, "full callback queue did not abort"); + require(callbacks->reads == 1, "queue overflow invoked an inline read"); + c.disconnected(); + require(callbacks->closes == 0, "disconnect overlapped running read"); + callbacks->release.set_value(); + for (int i = 0; i < 100 && callbacks->closes == 0; ++i) vTaskDelay(1); + require(callbacks->closes == 1 && callbacks->reads == 1, "stale reads ran after disconnect"); + } + std::cout << name << " passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/l2cap/README.md b/tests/l2cap/README.md new file mode 100644 index 00000000..ca39e689 --- /dev/null +++ b/tests/l2cap/README.md @@ -0,0 +1,38 @@ +# L2CAP host tests + +These tests compile the production `NimBLEL2CAPChannel.cpp` with small replacements for NimBLE and FreeRTOS. Separate host, callback and writer threads exercise send errors, buffer ownership, early credit notifications, disconnects, whole-message serialization, timeout cleanup, reconnect and callback queue overflow. + +```sh +make test-l2cap +make test-l2cap SANITIZER=address +make test-l2cap SANITIZER=thread +``` + +Clang, CMake and a C++17 compiler are required. Address builds also enable undefined-behavior checks. The deferred-callback configuration and an inline-callback build are both compiled. + +The fake radio does not validate ESP32 scheduling, RF behavior, controller credits or memory-pool sizing. Real-device transfer and disconnect tests remain necessary. Timeouts deliberately exercise the production two-second deadline. The lifecycle cases repeatedly create and destroy channels and verify that the shared callback task and queue are released after the last channel. + +## Callback and ownership contract + +Whole writes are serialized and a failed incomplete write closes the channel. +Inline onConnect/onRead callbacks remain on the host by default. They must not +block; use an application worker for writes that may need credits. The optional +read-offload mode moves those callbacks to a shared worker. Disconnect callbacks +always use that worker and follow running deferred reads. Stop accepting, request +disconnect and wait for `isIdle()` before deleting an application-owned channel; +never delete it inside one of its callbacks. The library owns/deletes callback +objects passed to the constructor/service. Creating/destroying channels belongs +to one application initialization/management task. + +This change allocates full-SDU mbufs (five by default) instead of subdividing +SDUs into 250-byte pool blocks. Pool count is configurable; larger MTUs/counts +cost more per-channel heap. The shared worker is released after the last channel. + +CI compiles a CoC-enabled probe with inline and deferred callbacks on IDF 5.4.3 +and 5.5.1. These are compile/link checks; real radio tests remain separate. + +`.github/tests/l2cap-build/main/main.cpp` shows bounded, worker-driven echo. +Callbacks queue copied bytes, overflow disconnects, and a generation counter +rejects old queued work after reconnect. Its service/channel live for the process +lifetime; a product with teardown must also stop/drain its application worker +before deleting the channel. The CI probe does not advertise or perform RF tests. diff --git a/tests/l2cap/stubs/Host.h b/tests/l2cap/stubs/Host.h new file mode 100644 index 00000000..9ceea2d3 --- /dev/null +++ b/tests/l2cap/stubs/Host.h @@ -0,0 +1,277 @@ +#pragma once +// Host replacements for the radio and RTOS; tests compile the actual channel implementation. +#define NIMBLE_CPP_CLIENT_H_ +#define NIMBLE_CPP_LOG_H_ +#define NIMBLE_CPP_UTILS_H_ +#define NIMBLE_LOGI(...) ((void)0) +#define NIMBLE_LOGD(...) ((void)0) +#define NIMBLE_LOGW(...) ((void)0) +#define NIMBLE_LOGE(...) ((void)0) +#define CONFIG_BT_NIMBLE_ENABLED 1 +#ifndef CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS +# define CONFIG_NIMBLE_CPP_L2CAP_DEFERRED_READ_CALLBACKS 1 +#endif +#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_QUEUE_LENGTH 4 +#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_STACK_SIZE 4096 +#define CONFIG_NIMBLE_CPP_L2CAP_CALLBACK_TASK_PRIORITY 5 +#define CONFIG_NIMBLE_CPP_L2CAP_SDU_BUFFER_COUNT 6 +#define MYNEWT_VAL(x) MYNEWT_VAL_##x +#define MYNEWT_VAL_BLE_L2CAP_COC_MAX_NUM 2 +#define MYNEWT_VAL_BLE_ROLE_CENTRAL 0 +#define MYNEWT_VAL_MSYS_1_BLOCK_COUNT 24 +#define MYNEWT_VAL_MSYS_1_BLOCK_SIZE 256 +#define MYNEWT_VAL_BLE_TRANSPORT_ACL_FROM_LL_COUNT 24 +#define MYNEWT_VAL_BLE_L2CAP_COC_MPS 256 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using TickType_t = uint32_t; +using BaseType_t = int; +constexpr int pdTRUE = 1, pdFALSE = 0, pdPASS = 1; +constexpr TickType_t portMAX_DELAY = UINT32_MAX; +#define pdMS_TO_TICKS(x) (x) +struct HostSemaphore { + std::mutex mutex; + std::condition_variable cv; + bool available = false; +}; +using SemaphoreHandle_t = HostSemaphore*; +inline SemaphoreHandle_t xSemaphoreCreateBinary() { + return new HostSemaphore; +} +inline SemaphoreHandle_t xSemaphoreCreateMutex() { + auto s = new HostSemaphore; + s->available = true; + return s; +} +inline int xSemaphoreTake(SemaphoreHandle_t s, TickType_t timeout) { + std::unique_lock l(s->mutex); + if (!s->cv.wait_for(l, std::chrono::milliseconds(timeout == portMAX_DELAY ? 2000 : timeout), [&] { + return s->available; + })) + return pdFALSE; + s->available = false; + return pdTRUE; +} +inline int xSemaphoreGive(SemaphoreHandle_t s) { + std::lock_guard l(s->mutex); + s->available = true; + s->cv.notify_one(); + return pdTRUE; +} +inline void vSemaphoreDelete(SemaphoreHandle_t s) { + delete s; +} +inline void vTaskDelay(uint32_t ms) { + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); +} +inline void ble_npl_time_delay(uint32_t ms) { + vTaskDelay(ms); +} +inline uint32_t ble_npl_time_ms_to_ticks32(uint32_t ms) { + return ms; +} +struct os_mempool { + const char* name = nullptr; +}; +struct os_mbuf_pool {}; +struct os_mbuf_pkthdr { uint32_t length; }; +inline size_t hostPayloadCapacity = 0; +using os_membuf_t = uint8_t; +inline std::atomic liveMbufs{0}; +inline std::atomic appendError{0}; +struct os_mbuf { + std::vector bytes; + os_mbuf(std::vector data = {}) : bytes(std::move(data)) { ++liveMbufs; } + ~os_mbuf() { --liveMbufs; } +}; +#define OS_MEMPOOL_SIZE(n, s) ((n) * (s)) +#define OS_MBUF_PKTLEN(m) ((m)->bytes.size()) +inline int os_mempool_init(os_mempool* p, size_t, size_t, void*, const char* n) { + p->name = n; + return 0; +} +inline int os_mbuf_pool_init(os_mbuf_pool*, os_mempool*, size_t blockSize, size_t) { + hostPayloadCapacity = blockSize - sizeof(os_mbuf) - sizeof(os_mbuf_pkthdr); + return 0; +} +inline void os_mempool_unregister(os_mempool*) {} +inline os_mbuf* os_mbuf_get_pkthdr(os_mbuf_pool*, int) { + return new os_mbuf; +} +inline int os_mbuf_append(os_mbuf* b, const void* p, size_t n) { + if (appendError.load()) return appendError.load(); + auto a = (const uint8_t*)p; + b->bytes.insert(b->bytes.end(), a, a + n); + return 0; +} +inline int os_mbuf_free_chain(os_mbuf* b) { + delete b; + return 0; +} +inline int os_mbuf_copydata(os_mbuf* b, size_t off, size_t n, void* p) { + memcpy(p, b->bytes.data() + off, n); + return 0; +} +constexpr int BLE_HS_EAGAIN = 1, BLE_HS_ENOMEM = 6, BLE_HS_ENOTCONN = 7, BLE_HS_EINVAL = 3, BLE_HS_EALREADY = 2, + BLE_HS_EBADDATA = 10, BLE_HS_EBUSY = 15, BLE_HS_EREJECT = 14, BLE_HS_ESTALLED = 31, BLE_HS_EUNKNOWN = 17, + BLE_HS_ETIMEOUT = 13; +constexpr uint16_t BLE_HS_CONN_HANDLE_NONE = 0xffff; +struct ble_l2cap_chan {}; +struct ble_l2cap_chan_info { + uint16_t peer_coc_mtu = 8, our_coc_mtu = 8, peer_l2cap_mtu = 8, our_l2cap_mtu = 8, scid = 1, dcid = 2, psm = 129; +}; +constexpr int BLE_L2CAP_EVENT_COC_CONNECTED = 1, BLE_L2CAP_EVENT_COC_DISCONNECTED = 2, BLE_L2CAP_EVENT_COC_ACCEPT = 3, + BLE_L2CAP_EVENT_COC_DATA_RECEIVED = 4, BLE_L2CAP_EVENT_COC_TX_UNSTALLED = 5; +struct ble_l2cap_event { + int type = 0; + struct { + ble_l2cap_chan* chan; + } connect{}, accept{}, disconnect{}; + struct { + os_mbuf* sdu_rx; + } receive{}; + struct { + int status; + ble_l2cap_chan* chan; + } tx_unstalled{}; +}; +inline std::function hostSend; +inline std::atomic hostDisconnects{0}; +inline int ble_l2cap_get_chan_info(ble_l2cap_chan* c, ble_l2cap_chan_info* i) { + if (!c) return BLE_HS_ENOTCONN; + *i = {}; + return 0; +} +inline uint16_t ble_l2cap_get_conn_handle(ble_l2cap_chan*) { + return 1; +} +inline int ble_l2cap_send(ble_l2cap_chan*, os_mbuf* b) { + return hostSend(b); +} +inline int ble_l2cap_disconnect(ble_l2cap_chan*) { + ++hostDisconnects; + return 0; +} +inline int ble_l2cap_recv_ready(ble_l2cap_chan*, os_mbuf* b) { + delete b; + return 0; +} +class NimBLEUtils { + public: + static const char* returnCodeToString(int) { return "stub"; } +}; +#include +using TaskHandle_t = void*; +struct HostTaskExit {}; +struct HostTask { + std::atomic cancelled{false}; + std::thread thread; +}; +inline thread_local HostTask* currentHostTask = nullptr; +inline std::atomic hostTaskCount{0}; +inline std::atomic hostQueueCount{0}; +inline TaskHandle_t xTaskGetCurrentTaskHandle() { + thread_local int token; + return &token; +} +inline int xTaskCreate(void (*fn)(void*), const char*, uint32_t, void* arg, int, TaskHandle_t* handle) { + auto task = new HostTask; + *handle = task; + ++hostTaskCount; + task->thread = std::thread([=] { + currentHostTask = task; + try { fn(arg); } catch (const HostTaskExit&) {} + }); + return pdPASS; +} +inline void vTaskDelete(TaskHandle_t handle) { + auto task = static_cast(handle); + assert(task && task != currentHostTask); + task->cancelled = true; + task->thread.join(); + delete task; + --hostTaskCount; +} +struct HostQueue { + std::mutex mutex; + std::condition_variable cv; + size_t capacity, width; + std::deque> items; +}; +using QueueHandle_t = HostQueue*; +inline QueueHandle_t xQueueCreate(size_t n, size_t w) { + ++hostQueueCount; + auto q = new HostQueue; + q->capacity = n; + q->width = w; + return q; +} +inline void vQueueDelete(QueueHandle_t q) { + assert(q->items.empty()); + delete q; + --hostQueueCount; +} +inline size_t uxQueueSpacesAvailable(QueueHandle_t q) { + std::lock_guard l(q->mutex); + return q->capacity - q->items.size(); +} +inline int xQueueSend(QueueHandle_t q, const void* item, TickType_t) { + std::lock_guard l(q->mutex); + if (q->items.size() == q->capacity) return pdFALSE; + auto b = (const uint8_t*)item; + q->items.emplace_back(b, b + q->width); + q->cv.notify_one(); + return pdTRUE; +} +inline int xQueueReceive(QueueHandle_t q, void* item, TickType_t) { + std::unique_lock l(q->mutex); + while (q->items.empty()) { + if (currentHostTask && currentHostTask->cancelled.load()) { throw HostTaskExit{}; } + q->cv.wait_for(l, std::chrono::milliseconds(1)); + } + memcpy(item, q->items.front().data(), q->width); + q->items.pop_front(); + return pdTRUE; +} +struct ble_npl_event { + void (*fn)(ble_npl_event*) = nullptr; + void* arg = nullptr; +}; +inline void ble_npl_event_init(ble_npl_event* e, void (*fn)(ble_npl_event*), void* p) { + e->fn = fn; + e->arg = p; +} +inline void ble_npl_event_deinit(ble_npl_event*) {} +inline void* ble_npl_event_get_arg(ble_npl_event* e) { + return e->arg; +} +inline QueueHandle_t nimble_port_get_dflt_eventq() { + static auto q = [] { + auto q = xQueueCreate(128, sizeof(ble_npl_event*)); + std::thread([=] { + for (;;) { + ble_npl_event* e; + xQueueReceive(q, &e, portMAX_DELAY); + e->fn(e); + } + }).detach(); + return q; + }(); + return q; +} +inline void ble_npl_eventq_put(QueueHandle_t q, ble_npl_event* e) { + const auto queued = xQueueSend(q, &e, 0); + assert(queued == pdTRUE); + (void)queued; +} diff --git a/tests/l2cap/stubs/freertos/FreeRTOS.h b/tests/l2cap/stubs/freertos/FreeRTOS.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/freertos/FreeRTOS.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/freertos/queue.h b/tests/l2cap/stubs/freertos/queue.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/freertos/queue.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/freertos/semphr.h b/tests/l2cap/stubs/freertos/semphr.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/freertos/semphr.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/freertos/task.h b/tests/l2cap/stubs/freertos/task.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/freertos/task.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/host/ble_gap.h b/tests/l2cap/stubs/host/ble_gap.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/host/ble_gap.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/host/ble_hs.h b/tests/l2cap/stubs/host/ble_hs.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/host/ble_hs.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/host/ble_l2cap.h b/tests/l2cap/stubs/host/ble_l2cap.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/host/ble_l2cap.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/nimble/nimble_npl.h b/tests/l2cap/stubs/nimble/nimble_npl.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/nimble/nimble_npl.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/nimble/nimble_port.h b/tests/l2cap/stubs/nimble/nimble_port.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/nimble/nimble_port.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/os/os_mbuf.h b/tests/l2cap/stubs/os/os_mbuf.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/os/os_mbuf.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tests/l2cap/stubs/syscfg/syscfg.h b/tests/l2cap/stubs/syscfg/syscfg.h new file mode 100644 index 00000000..dcdc9c8a --- /dev/null +++ b/tests/l2cap/stubs/syscfg/syscfg.h @@ -0,0 +1 @@ +#include "Host.h" diff --git a/tools/l2cap-bench/.gitignore b/tools/l2cap-bench/.gitignore new file mode 100644 index 00000000..b45f5596 --- /dev/null +++ b/tools/l2cap-bench/.gitignore @@ -0,0 +1,2 @@ +results/ +__pycache__/ diff --git a/tools/l2cap-bench/README.md b/tools/l2cap-bench/README.md new file mode 100644 index 00000000..85667579 --- /dev/null +++ b/tools/l2cap-bench/README.md @@ -0,0 +1,80 @@ +# Serial runner + +Python 3 and pyserial are required. The ESP-IDF Python environment includes +pyserial. Start the macOS reflector or an ESP in `role server` before running. + +For the ECOS GAP roles, launch the Mac app with +`bash tools/run-l2cap-reflector-app.sh --connect L2CAP-Bench-ESP` from the +core-bluetooth-tool repository, then use `--dut-role peripheral` below. The +ESP still initiates and times all benchmark messages; Apple opens GAP and CoC. + +```sh +python bench.py run --port /dev/serial/by-id/ \ + --peer auto --pattern echo --sizes 64,256,1024,4096 \ + --count 64 --warmup 8 --repeat 3 --label mac-s3-baseline +python bench.py run --port /dev/serial/by-id/ \ + --pattern request --request-size 8 --sizes 32,256,1024,4096 \ + --count 64 --repeat 3 --label mac-s3-request +python bench.py compare results/*baseline.json results/*candidate.json +``` + +```sh +python bench.py run --port /dev/serial/by-id/ --dut-role peripheral \ + --pattern request --request-size 8 --sizes 32,256,1024,4096 \ + --count 64 --warmup 8 --repeat 3 --label apple-central-s3 +``` + +Sizes and request/response direction are always relative to the ESP DUT: +`request --request-size 8 --sizes 4096` loads Apple→ESP; use +`request --request-size 4096 --sizes 32` to load ESP→Apple. These are raw +transport patterns; the separate ECOS benchmark reproduces product requests. +Use `--require-params` for A/B runs. Without it, refused interval/PHY requests +remain measurable, with a warning and the actual values stored in the file. + +For ESP↔ESP, use `--peer L2CAP-Bench-ESP --peer-port `. The runner +sets that board to reflector mode. Keep board placement fixed and record distance, +orientation, Mac/iPhone model and any concurrent radio use in `--notes`. + +Default output is `tools/l2cap-bench/results/` regardless of the working +directory; this directory is Git-ignored. `--output` selects another path. + +The benchmark firmware requires ESP-IDF 6.1. Each file contains firmware identity, negotiated parameters, all per-packet +samples, warmup events and the aggregate results. The full serial stream stays +beside it as `.log`; an optional reflector console has `.peer.log`. Results are +saved after each measured size, including partial failures. `compare` refuses +failed runs and reports the median of repeated run medians/p95 values. + +The four-byte frame header is excluded from `--sizes`; sequence/token and +turnaround are included. See the firmware README for the exact wire layout and +timing definitions. RTT changes compare with the first matching file supplied +to `compare` within the same GAP role. Warmup measurements are retained but +excluded from its table. The role column prevents silently mixing inverse and +product-role baselines. Missing/reordered samples fail the run, including warmup. + +Streaming and reconnect examples (matching version-2 firmware/peer): + +```sh +python bench.py run --port /dev/serial/by-id/ --dut-role peripheral \ + --pattern duplex --sizes 64,4096 --count 128 --warmup 8 --repeat 3 \ + --require-params --label duplex +python bench.py run --port /dev/serial/by-id/ --dut-role peripheral \ + --pattern duplex --sizes 64,4096 --count 4 --warmup 0 --repeat 1 \ + --cycles 100 --require-params --label reconnect +``` + +`stream-tx` sends ESP→peer, `stream-rx` sends peer→ESP, `duplex` sends both. +For these patterns the CLI uses each `--sizes` value for both configured sizes. +Streaming has no RTT median: comparison tables use dashes and explicitly mark +changes as goodput changes. Goodput is the sum of enabled directions and includes +the START/FINISH round trips; inspect per-receiver arrival rates in the JSON too. + +Every enabled direction needs a complete per-frame sample sequence, matching +receipt, zero errors and a confirmed FINISH. Missing evidence fails the run. +Schema 2 adds `cycles`, per-run cycle numbers, heap trends in `lifecycle` and +parsed `txstats`; old echo/request files remain readable by `compare`. +The CLI fails on a broken cycle instead of hiding it with an automatic retry. +Teardown still runs and partial evidence is saved. Failed files cannot be used +as successful comparison baselines. + +Optional TX-stat records from separately instrumented firmware are parsed when +present. This example does not enable internal instrumentation or tuning. diff --git a/tools/l2cap-bench/bench.py b/tools/l2cap-bench/bench.py new file mode 100755 index 00000000..b5c965a2 --- /dev/null +++ b/tools/l2cap-bench/bench.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Drive the serial L2CAP bench. Raw samples are saved alongside the full log.""" +import argparse +import datetime as dt +import json +from pathlib import Path +import statistics +import re +import sys +import time + + +def tx_stats(line): + match = re.search(rb"BLETx:\s+(.*)", line) + if not match: + return None + fields = {key.decode(): [int(v) for v in value.split(b"/")] if b"/" in value else int(value) + for key, value in re.findall(rb"(\w+(?:/\w+)*)=([0-9/]+)", match[1])} + if not fields: + return None + fields["event"] = "txstats" + stamp = re.search(rb"\(([0-9]+)\)", line) + if stamp: + fields["log_timestamp_ms"] = int(stamp[1]) + return fields + + +class Console: + def __init__(self, port, log): + import serial + self.serial = serial.Serial(port, 115200, timeout=0.1) + self.log = log + self.buffer = bytearray() + self.events = [] + + def command(self, text, timeout=20): + self.serial.write((text + "\n").encode()) + deadline = time.monotonic() + timeout + received = [] + while time.monotonic() < deadline: + data = self.serial.read(8192) + if not data: + continue + self.log.write(data) + self.log.flush() + self.buffer.extend(data) + while b"\n" in self.buffer: + line, _, rest = self.buffer.partition(b"\n") + self.buffer = bytearray(rest) + marker = line.find(b"BENCH ") + if marker < 0: + stats = tx_stats(line) + if stats: + self.events.append(stats); received.append(stats) + continue + event = json.loads(line[marker + 6:]) + self.events.append(event) + received.append(event) + if event["event"] == "ready" and text.split()[0] not in ("status", "reboot"): + raise RuntimeError(f"device restarted during {text!r}") + if event["event"] == "error": + print("DUT:", event["message"], file=sys.stderr) + if event["event"] == "command-end": + if event["code"] != 0: + raise RuntimeError(f"{text!r} failed with code {event['code']}") + return received + raise TimeoutError(f"No command-end for {text!r} after {timeout}s") + + def close(self): + self.serial.close() + + +def save(path, document): + temporary = path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(document, indent=2) + "\n") + temporary.replace(path) + + +def checked_run(events, requested_count): + result = next(event for event in events if event["event"] == "run-result") + samples = [event for event in events if event["event"] == "sample"] + if [s["seq"] for s in samples] != list(range(result["completed"])): + raise RuntimeError("serial evidence is incomplete: missing or reordered samples") + if result["success"] and result["completed"] != requested_count: + raise RuntimeError("successful run reported an unexpected sample count") + return result, samples + + +def params_match(params, args): + return (params.get("interval_ms") == args.interval and + params.get("tx_phy") == args.phy and params.get("rx_phy") == args.phy) + + +def checked_measurement(events, count, pattern, request_size, response_size): + if pattern in ("echo", "request"): + return checked_run(events, count) + result = next(event for event in events if event["event"] == "run-result") + expected_tx = count if pattern != "stream-rx" else 0 + expected_rx = count if pattern != "stream-tx" else 0 + samples = [event for event in events if event["event"] in ("stream-tx-sample", "stream-rx-sample")] + for direction, expected, size, summary_name in (("tx", expected_tx, request_size, "peer_rx"), + ("rx", expected_rx, response_size, "local_rx")): + directional = [s for s in samples if s["event"] == f"stream-{direction}-sample"] + if [s["seq"] for s in directional] != list(range(expected)): + raise RuntimeError(f"incomplete stream {direction} sample evidence") + if result[f"{direction}_frames"] != expected: + raise RuntimeError(f"stream {direction} count mismatch") + if expected: + summary = result[summary_name] + if (summary["token"] != result["token"] or summary["frames"] != expected or + summary["bytes"] != expected * size or summary["errors"] != 0 or + summary["first_us"] > summary["last_us"]): + raise RuntimeError(f"invalid stream {direction} receipt") + if direction == "rx": + arrivals = [s["received_at_us"] for s in directional] + if (arrivals != sorted(arrivals) or arrivals[0] != summary["first_us"] or + arrivals[-1] != summary["last_us"]): + raise RuntimeError("inconsistent stream arrival evidence") + if (not result["success"] or result["completed"] != count or + not result["peer_finish_confirmed"] or result["callback_faults"]): + raise RuntimeError("stream did not complete on both peers") + return result, samples + + +def lifecycle_summary(cycles): + settled = cycles[min(5, max(0, len(cycles) - 2)):] + heaps = [c["disconnected_heap"] for c in settled] + result = {"completed_cycles": len(cycles), "warmup_cycles_excluded": len(cycles) - len(settled)} + for key in ("free", "internal_free", "largest_internal_block", "tasks"): + values = [h[key] for h in heaps] + result[key] = {"first": values[0], "last": values[-1], "min": min(values), "max": max(values)} + if len(values) > 1: + result[key]["slope_per_cycle"] = statistics.linear_regression(range(len(values)), values).slope + return result + + +def run(args): + sizes = [int(value) for value in args.sizes.split(",")] + if (not 1 <= args.count <= 4096 or not 0 <= args.warmup <= 4096 or args.repeat < 1 or getattr(args, "cycles", 1) < 1 or + not sizes or any(not 12 <= size <= 4996 for size in sizes) or + not 8 <= args.request_size <= 4996): + raise ValueError("count: 1..4096, warmup: 0..4096, repeat: >=1, sizes: 12..4996, request-size: 8..4996") + now = dt.datetime.now(dt.timezone.utc) + path = (Path(args.output) if args.output else + Path(__file__).resolve().parent / "results" / f"{now:%Y%m%dT%H%M%SZ}-{args.label}.json") + path.parent.mkdir(parents=True, exist_ok=True) + document = {"schema": 2, "created_at": now.isoformat(), "label": args.label, + "peer_name": args.peer, "dut_gap_role": args.dut_role, + "notes": args.notes, "runs": [], "success": False} + dut = peer = None + with path.with_suffix(".log").open("wb") as log: + try: + if args.peer_port: + peer_log = path.with_suffix(".peer.log").open("wb") + peer = Console(args.peer_port, peer_log) + peer_status = peer.command("status") + if any(e.get("tx_diagnostics") for e in peer_status if e["event"] == "hello"): + raise ValueError("ESP reflector TX diagnostics need a continuous peer-console reader; enable diagnostics on the DUT only") + peer.command("role server") + dut = Console(args.port, log) + initial = dut.command("status") + document["initial_status"] = initial + document["cycles"] = [] + for cycle in range(getattr(args, "cycles", 1)): + document["cycle_in_progress"] = cycle + if args.peer_port and cycle: + peer.command("role server") + if args.dut_role == "central": + dut.command("role client") + dut.command(f"params {args.interval} {args.phy}") + if args.dut_role == "peripheral": + dut.command("listen") + connected_events = dut.command("await 30", timeout=40) + else: + connected_events = dut.command(f"connect {args.peer}", timeout=40) + negotiated = next(e for e in reversed(connected_events) + if e["event"] == "params" and e.get("connected")) + document["negotiated"] = negotiated + document.setdefault("parameters_match", True) + document["parameters_match"] &= params_match(negotiated, args) + print(f"Connected cycle {cycle + 1}: DUT {args.dut_role}, interval {negotiated.get('interval_ms')} ms, " + f"PHY {negotiated.get('tx_phy')}/{negotiated.get('rx_phy')}, " + f"DLE {negotiated.get('tx_octets')}/{negotiated.get('rx_octets')}, " + f"MTU {negotiated['mtu']}", flush=True) + if not params_match(negotiated, args): + print("Peer did not accept the requested interval/PHY", file=sys.stderr) + if args.require_params: + raise RuntimeError("negotiated parameters do not match the requested A/B setup") + for repeat in range(args.repeat): + for size in sizes: + streaming = args.pattern in ("stream-tx", "stream-rx", "duplex") + request = size if args.pattern == "echo" or streaming else args.request_size + warmup = [] + if args.warmup: + warmup = dut.command(f"run {args.pattern} {request} {size} {args.warmup}", + timeout=args.warmup * 6 + 30) + if not checked_measurement(warmup, args.warmup, args.pattern, request, size)[0]["success"]: + raise RuntimeError("warmup failed") + events = dut.command(f"run {args.pattern} {request} {size} {args.count}", + timeout=args.count * 6 + 30) + result, samples = checked_measurement(events, args.count, args.pattern, request, size) + run_params = [e for e in events if e["event"] == "params" and e.get("connected")] + if args.require_params and len(run_params) < 2: + raise RuntimeError("missing link parameters before/after the measured run") + if any(not params_match(e, args) for e in run_params): + document["parameters_match"] = False + if args.require_params: + raise RuntimeError("link parameters changed during the comparison") + document["runs"].append({"cycle": cycle, "repeat": repeat, "pattern": args.pattern, + "request_size": request, "response_size": size, + "warmup": warmup, "params": run_params, + "result": result, "samples": samples, + "txstats": [e for e in events if e["event"] == "txstats"]}) + document["events"] = dut.events + save(path, document) + timing = (f"TX {result['tx_frames']}, RX {result['rx_frames']}, " if streaming else + f"median {result['rtt_median_us'] / 1000:.3f} ms, p95 {result['rtt_p95_us'] / 1000:.3f} ms, ") + print(f"{args.pattern} {request}->{size}, cycle {cycle + 1}, repeat {repeat + 1}: " + timing + + f"{result.get('goodput_bytes_per_s', 0) / 1024:.2f} KiB/s, " + f"ok={result['success']}", flush=True) + if not result["success"]: + raise RuntimeError("benchmark reported a failed run") + disconnected = dut.command("disconnect", timeout=10) + heap = next(e for e in reversed(disconnected) if e["event"] == "heap") + document["cycles"].append({"cycle": cycle, "negotiated": negotiated, "disconnected_heap": heap}) + document["events"] = dut.events + save(path, document) + document.pop("cycle_in_progress", None) + document["lifecycle"] = lifecycle_summary(document["cycles"]) + document["success"] = True + except Exception as error: + document["error"] = str(error) + raise + finally: + if dut: + try: + if "cycle_in_progress" in document or not document.get("cycles"): + dut.command("disconnect", timeout=10) + except Exception as error: + document["disconnect_error"] = str(error) + document["success"] = False + document["events"] = dut.events + dut.close() + if peer: + try: + peer.command("role client", timeout=10) + except Exception as error: + document["peer_disconnect_error"] = str(error) + document["success"] = False + document["peer_events"] = peer.events + peer.close() + peer_log.close() + save(path, document) + print(f"Saved {path}", flush=True) + if document.get("disconnect_error") or document.get("peer_disconnect_error"): + raise RuntimeError(document.get("disconnect_error") or document["peer_disconnect_error"]) + + +def compare(args): + print("| File | DUT GAP role | Interval ms | PHY TX/RX | MTU | Pattern | Request → response | Median RTT ms | p95 ms | Goodput KiB/s | Change basis | Change |") + print("|---|---|---:|---|---:|---|---:|---:|---:|---:|---|---:|") + baseline = {} + for path in map(Path, args.files): + document = json.loads(path.read_text()) + if not document["success"]: + raise ValueError(f"{path}: incomplete or failed measurement file") + grouped = {} + role = document.get("dut_gap_role", "central") + fallback_params = document.get("negotiated") or next( + (e for e in document.get("events", []) if e["event"] == "params" and e.get("connected")), {}) + for run in document["runs"]: + recorded_params = run.get("params") or [fallback_params] + params = recorded_params[0] + link_fields = ("interval_ms", "tx_phy", "rx_phy", "mtu", "tx_octets", "rx_octets") + if any(tuple(p.get(k) for k in link_fields) != tuple(params.get(k) for k in link_fields) + for p in recorded_params[1:]): + raise ValueError(f"{path}: link parameters changed within a run") + key = (run["pattern"], run["request_size"], run["response_size"], + params.get("interval_ms"), params.get("tx_phy"), params.get("rx_phy"), + params.get("mtu"), params.get("tx_octets"), params.get("rx_octets")) + if not run["result"]["success"]: + raise ValueError(f"{path}: failed run cannot be compared as a successful baseline") + grouped.setdefault(key, []).append(run["result"]) + for key, results in grouped.items(): + timed = "rtt_median_us" in results[0] + median = statistics.median(r["rtt_median_us"] for r in results) if timed else None + p95 = statistics.median(r["rtt_p95_us"] for r in results) if timed else None + goodput = statistics.median(r["goodput_bytes_per_s"] for r in results) + metric = median if timed else goodput + reference = baseline.setdefault((role,) + key, metric) + change = 100 * (metric / reference - 1) if reference else 0 + median_text = f"{median / 1000:.3f}" if timed else "—" + p95_text = f"{p95 / 1000:.3f}" if timed else "—" + basis = "RTT" if timed else "goodput" + print(f"| {path.name} | {role} | {key[3]} | {key[4]}/{key[5]} | {key[6]} | {key[0]} | {key[1]} → {key[2]} | {median_text} | " + f"{p95_text} | {goodput / 1024:.2f} | {basis} | {change:+.1f}% |") + + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + measure = commands.add_parser("run") + measure.add_argument("--port", required=True) + measure.add_argument("--peer", default="auto", help="Advertised name, BLE address, or auto (exactly one benchmark service)") + measure.add_argument("--peer-port", help="Also configure an ESP reflector over this serial port") + measure.add_argument("--dut-role", choices=["central", "peripheral"], default="central", + help="Peripheral reproduces Apple-Central/ESP-Peripheral product roles") + measure.add_argument("--pattern", choices=["echo", "request", "stream-tx", "stream-rx", "duplex"], default="echo") + measure.add_argument("--sizes", default="64,256,1024,4096") + measure.add_argument("--request-size", type=int, default=8) + measure.add_argument("--cycles", type=int, default=1, help="Connect/run/disconnect cycles; heap is captured after each teardown") + measure.add_argument("--count", type=int, default=64) + measure.add_argument("--warmup", type=int, default=8) + measure.add_argument("--repeat", type=int, default=3) + measure.add_argument("--interval", type=float, default=15) + measure.add_argument("--phy", type=int, choices=[1, 2], default=2) + measure.add_argument("--require-params", action="store_true", + help="Fail if the peer does not accept the requested interval/PHY (for A/B tests)") + measure.add_argument("--label", default="baseline") + measure.add_argument("--notes", default="") + measure.add_argument("--output") + measure.set_defaults(func=run) + comparison = commands.add_parser("compare") + comparison.add_argument("files", nargs="+") + comparison.set_defaults(func=compare) + args = parser.parse_args() + if args.command == "run" and args.peer_port and args.dut_role != "central": + parser.error("--peer-port currently requires --dut-role central") + try: + args.func(args) + except Exception as error: + print(str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/l2cap-bench/test_bench.py b/tools/l2cap-bench/test_bench.py new file mode 100644 index 00000000..38767529 --- /dev/null +++ b/tools/l2cap-bench/test_bench.py @@ -0,0 +1,124 @@ +import io +import json +import copy +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import unittest + +import bench + + +class EvidenceTests(unittest.TestCase): + def test_reboot_during_long_run_fails_without_waiting_for_full_command_budget(self): + class Serial: + def write(self, data): pass + def read(self, count): return b'BENCH {"event":"ready"}\n' + console = bench.Console.__new__(bench.Console) + console.serial = Serial(); console.log = io.BytesIO() + console.buffer = bytearray(); console.events = [] + with self.assertRaisesRegex(RuntimeError, "restarted"): + console.command("run duplex 4096 4096 4096", timeout=3600) + + def test_tx_diagnostics_preserve_credit_and_stall_pairs(self): + result = bench.tx_stats(b"I (120) BLETx: sdus=32 bytes=40000 credits_min/max=0/100 stalls=2 errors=0 queue_us_avg/max=4/10 send_us_avg/max=6/12 stall_us_total/max=100/70 (host submission, not RF)") + self.assertEqual(result["credits_min/max"], [0, 100]) + self.assertEqual(result["stall_us_total/max"], [100, 70]) + self.assertEqual(result["log_timestamp_ms"], 120) + self.assertEqual(result["event"], "txstats") + self.assertIsNone(bench.tx_stats(b"I (120) other log line")) + def test_stream_requires_both_receipts_and_complete_sample_sequences(self): + receipt = {"token": 42, "frames": 2, "bytes": 128, "errors": 0, "first_us": 100, "last_us": 200} + result = {"event": "run-result", "success": True, "completed": 2, "token": 42, + "tx_frames": 2, "rx_frames": 2, "callback_faults": 0, "peer_finish_confirmed": True, + "local_rx": copy.deepcopy(receipt), "peer_rx": copy.deepcopy(receipt)} + events = [result] + [{"event": "stream-tx-sample", "seq": i, "tx_submit_us": 1} for i in range(2)] + events += [{"event": "stream-rx-sample", "seq": i, "received_at_us": 100 + 100 * i} for i in range(2)] + bench.checked_measurement(events, 2, "duplex", 64, 64) + bad = copy.deepcopy(events); bad.pop() + with self.assertRaisesRegex(RuntimeError, "incomplete"): + bench.checked_measurement(bad, 2, "duplex", 64, 64) + for summary, key, value in (("peer_rx", "bytes", 127), ("peer_rx", "token", 41), + ("local_rx", "errors", 1), ("local_rx", "last_us", 201)): + bad = copy.deepcopy(events); bad[0][summary][key] = value + with self.assertRaises(RuntimeError): + bench.checked_measurement(bad, 2, "duplex", 64, 64) + bad = copy.deepcopy(events); bad[0]["peer_finish_confirmed"] = False + with self.assertRaises(RuntimeError): + bench.checked_measurement(bad, 2, "duplex", 64, 64) + + def test_lifecycle_excludes_initial_allocations_but_retains_a_leak_trend(self): + cycles = [{"disconnected_heap": {"free": 10000 - i * 64, "internal_free": 10000 - i * 64, + "largest_internal_block": 4000, "tasks": 7}} for i in range(10)] + result = bench.lifecycle_summary(cycles) + self.assertEqual(result["warmup_cycles_excluded"], 5) + self.assertEqual(result["free"]["slope_per_cycle"], -64) + self.assertEqual(result["tasks"]["slope_per_cycle"], 0) + + def test_empty_measurement_cannot_be_reported_as_success(self): + for field, value in (("count", 0), ("repeat", 0), ("warmup", -1), ("cycles", 0)): + arguments = dict(sizes="64", count=64, repeat=3, warmup=8, request_size=8) + arguments[field] = value + with self.assertRaises(ValueError): + bench.run(SimpleNamespace(**arguments)) + + def test_comparison_keeps_distinct_link_parameters_separate(self): + with TemporaryDirectory() as directory: + paths = [] + for index, (interval, mtu, rtt) in enumerate([(15, 1251, 100), (30, 1251, 200), + (15, 2048, 300), (15, 1251, 120)]): + path = Path(directory) / f"run-{index}.json" + path.write_text(json.dumps({"success": True, "dut_gap_role": "peripheral", "runs": [{ + "pattern": "echo", "request_size": 64, "response_size": 64, + "params": [{"interval_ms": interval, "mtu": mtu, "tx_phy": 2, "rx_phy": 2, + "tx_octets": 251, "rx_octets": 251}], + "result": {"success": True, "rtt_median_us": rtt, "rtt_p95_us": rtt, + "goodput_bytes_per_s": 1000}}]})) + paths.append(str(path)) + output = io.StringIO() + with redirect_stdout(output): + bench.compare(SimpleNamespace(files=paths)) + changes = [line.split("|")[-2].strip() for line in output.getvalue().splitlines()[2:]] + self.assertEqual(changes, ["+0.0%", "+0.0%", "+0.0%", "+20.0%"]) + + def test_missing_or_duplicate_sample_cannot_pass(self): + result = {"event": "run-result", "success": True, "completed": 3} + for sequences in ([0, 2], [0, 1, 1], [0, 2, 1]): + events = [result] + [{"event": "sample", "seq": n} for n in sequences] + with self.assertRaisesRegex(RuntimeError, "incomplete"): + bench.checked_run(events, 3) + + def test_success_requires_requested_count(self): + events = [{"event": "run-result", "success": True, "completed": 1}, + {"event": "sample", "seq": 0}] + with self.assertRaisesRegex(RuntimeError, "unexpected sample count"): + bench.checked_run(events, 3) + + def test_serial_chunks_can_split_json_at_any_byte(self): + events = [{"event": "ready"}, {"event": "command-end", "code": 0}] + wire = b"boot log\n" + b"".join(b"BENCH " + json.dumps(e).encode() + b"\n" for e in events) + + class Serial: + def __init__(self): + self.chunks = iter(bytes([byte]) for byte in wire) + self.sent = [] + + def read(self, count): + return next(self.chunks, b"") + + def write(self, data): + self.sent.append(data) + + console = bench.Console.__new__(bench.Console) + console.serial = Serial() + console.log = io.BytesIO() + console.buffer = bytearray() + console.events = [] + self.assertEqual(console.command("status"), events) + self.assertEqual(console.log.getvalue(), wire) + self.assertEqual(console.serial.sent, [b"status\n"]) + + +if __name__ == "__main__": + unittest.main()