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 4 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?
|
|
It's not taking into account my desire to move to an RTOS world in the future where background tasks can be done by separate tasks. I do also think it simplifies the locking and memory allocation story a bit too. So, I disagree, for code within the ESP or Zephyr port we should use the RTOS primitive when we can. ringbuf can still be used for shared code though. |
That is fine, I will change it. I don't disagree with you. I was more noting that we can't avoid the polling. But it does make the code a little simpler, since the xQueue takes care of the storage management and is a better abstraction. Right now the ringbuf is ad hoc message passing, and touchy storage management. |
Per review, replace the interrupt-guarded ringbuf with a FreeRTOS queue, the RTOS-native primitive for task-to-task message passing. xQueueSend(), xQueueReceive(), and xQueueReset() are thread-safe by construction, so the hand-rolled critical sections go away, along with the port_malloc() storage management: the queue is created lazily from the FreeRTOS (IDF) heap on first use and never deleted. Queue-full still maps to BLE_HS_ENOMEM, and the drain loop and error handling are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@tannewt I switched to xQueue - ready for re-review |
This comment was mostly generated by Claude Code, with edits and additions by @dhalbert. 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 FreeRTOS queue; the VM task drains the queue and builds theService/Characteristic/Descriptorobjects.The queue is created on first use via
xQueueCreate(), from the FreeRTOS (IDF) heap — not the GC heap. It therefore needs no GC root, 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 created once and never deleted.The size of the queue was calculated based on the possible traffic backlog during discovery. Both models agreed on the size, with Fable doing a more thorough calculation.
The queue operations (
xQueueSend(),xQueueReceive(), and the per-stepxQueueReset()) are thread-safe by construction, so no explicit interrupt guarding is needed for the discovery staging.The fix originally used ringbuf.h instead of an xQueue; @tannewt requested using the native FreeRTOS mechanism in his review. The newer code is a little simpler.
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.After the change from ring buffer to FreeRTOS queue, retested on ESP32-S3 and nRF with the echo examples, in both role arrangements.