espressif/_bleio: build discovered GATT objects on the VM task#11104
espressif/_bleio: build discovered GATT objects on the VM task#11104dhalbert wants to merge 3 commits into
Conversation
Remote service/characteristic/descriptor discovery callbacks run on the NimBLE host task, not the CircuitPython VM task, and were allocating CircuitPython objects (mp_obj_malloc, etc.) directly in that context. Because the espressif port has no GIL (MICROPY_PY_THREAD == 0), an allocation on the host task can trigger a gc_collect() there, which scans the host task's stack instead of the VM task's and frees the still-live half-built discovery objects. The resulting heap corruption showed up as an interrupt-watchdog reset (ESP_RST_INT_WDT) partway through discovery. The callbacks now only copy the plain NimBLE result struct into a fixed ring buffer (a non-allocating memcpy, guarded with interrupts disabled since ringbuf ops are not atomic and the host and VM tasks preempt each other). The VM task drains the ring and builds the Python objects, so all heap allocation happens on the VM task. The ring storage is allocated on first use from the port (IDF) heap, so it needs no GC root, survives soft reloads, and costs no RAM until a central actually discovers services. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CharacteristicBuffer and PacketBuffer ringbufs are filled from the nimble_host task, which preempts the VM task at arbitrary points, but the non-atomic ringbuf operations were unguarded: the comment claiming the BLE host task "won't interrupt us" has been wrong since the code was written (the host task runs at a much higher priority on the same core). PacketBuffer's pending outgoing buffers have the same problem: write() on the VM task appends to them while queue_next_write() on the host task consumes them. Guard these operations with interrupts disabled, mirroring the nordic port's critical sections around the same code, in the same places. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
89359c9 to
97cf5c5
Compare
Restore the verbose prints dropped when discovery object construction moved off the NimBLE host task, and add matching error prints to all three discovery callbacks for consistency (only the descriptor callback had one before). The object prints now live in _build_characteristic() and _build_descriptor(), which run on the VM task. Also print the step status in _check_discovery_status(), which covers the timeout and ring-full errors that never pass through a callback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tannewt
left a comment
There was a problem hiding this comment.
Thanks for running down this bug. I think you should switch to using a freertos queue or similar instead of micropython's ringbuf. That way you'll get inter-task switching automatically instead of the polling this will do.
Indeed, this came up as one of the alternatives with Claude Fable. See below, particularly the italicized (by me) section, which concluded the code still has to poll. @tannewt What do you think after reading the below?
|
This comment was mostly generated by Claude Code. Claude wrote most of the PR with considerable guidance and dialog with me. I used Opus 4.8 for the bulk of the PR, then as an experiment I asked Fable 5 to review the changes. It caught one additional problem.
Problem
On espressif, connecting as a BLE central and discovering a peripheral's services crashes partway through discovery with an internal watchdog reset (
ESP_RST_INT_WDT→ safe mode "Internal watchdog timer expired."). It reproduces with the Adafruit_CircuitPython_BLEble_uart_echo_client.pyexample talking to a peripheral. It appears to be timing and storage sensitive: if the library is .py, not .mpy, or CircuitPython is built with-Os, the problem can go away. It has been present since before the ESP-IDF v6 migration.Root cause
The remote GATT discovery callbacks (
_discovered_service_cb(),_discovered_characteristic_cb(),_discovered_descriptor_cb()) are invoked by NimBLE on its host task (nimble_host), not the CircuitPython VM task (main). Both are pinned to core 1, with the host task at a much higher priority (21 vs 1). Those callbacks allocated CircuitPython objects (mp_obj_malloc(),common_hal_bleio_characteristic_construct(),mp_obj_list_append()) directly.The CircuitPython espressif port has no GIL (
MICROPY_PY_THREAD == 0) and assumes only the VM task touches the GC heap, so it is a mistake to do any gc heap allocation in other than the VM task. When an allocation on the host task triggers agc_collect(), the collection runs on the NimBLE host task andgc_helper_collect_regs_and_stack()scans that task's stack rather than the VM task's, so the still-live, half-built discovery objects (rooted only on the VM task's stack) are treated as unreachable and freed. The resulting use-after-free corrupts the heap and takes the chip down. (Confirmed with temporary instrumentation logging the task insidegc_collect()— it printednimble_hostimmediately before the reset.)Fix
Keep all heap allocation on the VM task. The discovery callbacks now only copy the plain NimBLE result struct (
ble_gatt_svc/ble_gatt_chr/ble_gatt_dsc— fixed-size PODs) into a fixed-size ring buffer; the VM task drains the ring and builds theService/Characteristic/Descriptorobjects. This mirrors the existingshared-module/_bleio/ScanResults.cpattern for BLE-callback to VM handoff.The ring buffer's 4 KB backing store is allocated on first use from the port (IDF) heap via
port_malloc()— not the GC heap — following the pattern of theport_malloc-backed ringbuf inshared-module/keypad/EventQueue.c. It therefore needs no GC root, lands in SPIRAM when available, survives soft reloads (so a stale procedure's late callbacks always write into live memory), and costs no RAM until a central actually discovers services. It is allocated once and never freed.[Originally both LLM models suggested a static 4kB ring buffer, favoring it over gc heap allocation to avoid possible issues if BLE was still running but the VM had terminated. I suggested using
port_malloc(), which overcame that worry. I tested on boards both with and without PSRAM to make sure it would be OK. --@dhalbert][The size of the ring buffer was calculated based on the possible traffic backlog during discovery. Both models agreed on the size, with Fable doing a more thorough calculation. --@dhalbert]
The ring buffer is guarded with
common_hal_mcu_disable_interrupts()/enable_interrupts()around each whole-recordput/get— and around the per-step ring reset — becauseringbufoperations are not atomic (sharedusedcounter) and the host and VM tasks preempt each other. The guarded region is a single small record copy, far below the interrupt-watchdog threshold.Also in this PR: fixing some missing ringbuf atomicity guards in
CharacteristicBufferandPacketBufferA second commit guards the other
_bleiostate shared with thenimble_hosttask, which had the same latent race: theCharacteristicBufferandPacketBufferringbuf operations, andPacketBuffer's pending outgoing buffers, whichwrite()appends to on the VM task whilequeue_next_write()consumes them on the host task. Guard placement mirrors the nordic port's critical sections. An incorrect comment claiming the host task "won't interrupt us" has been removed.Testing
Ran the
ble_uart_echo_client.pyexample against a peripheral runningble_uart_echo_test.pyon an nRF board, on three configurations: ESP32-S3 with 8 MB PSRAM, ESP32-S3 without PSRAM, and ESP32-C6. Also ran with the example programs swapped between Espressif and nRF. It works: discovery completes and the UART echo round-trips correctly on all three Espressif boards.