Skip to content

espressif: Don't lose PacketBuffer data when NimBLE runs out of buffers - #11286

Merged
dhalbert merged 1 commit into
adafruit:mainfrom
dhalbert:espressif-packetbuffer-redesign
Sep 1, 2026
Merged

espressif: Don't lose PacketBuffer data when NimBLE runs out of buffers#11286
dhalbert merged 1 commit into
adafruit:mainfrom
dhalbert:espressif-packetbuffer-redesign

Conversation

@dhalbert

@dhalbert dhalbert commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

🤖 Generated with Claude Code

Note by @dhalbert: this fix was reworked several times to be clean, and well tested. I've edited this note and the code comments for clarity. The functionality and implementation was also compared against those of other ports.

This is the follow-up promised in #11255, whose body noted this as "a known, pre-existing espressif PacketBuffer send bug, to be fixed in a following PR".

The problem

A burst of outgoing packets, such as a BLE workflow directory listing or long REPL output, exhausts NimBLE's mbuf pool. The pool drains only as fast as the radio sends, so BLE_HS_ENOMEM means the pool is temporarily empty rather than that anything went wrong, and it can stay empty for seconds. queue_next_write() incorrectly treated BLE_HS_ENOMEM as an error.

The visible symptom was a directory listing of 30+ files losing its final entry and the end-of-listing terminator, which left the web editor's file dialog spinning forever.

The fix

  • Keep the data when BLE_HS_ENOMEM occurs and retry the send, paced at 10 ms so retries don't outpace the radio. Real errors are still dropped.
  • Retry inline from the wait loops in write() and flush(). The BLE workflow calls both from inside a background callback, and background_callback_run_all() will not re-enter itself, so a retry scheduled only as a background callback cannot run while a writer is waiting there.
  • Drop the alternating second outgoing buffer and the index flipping. Every NimBLE send call copies the caller's buffer into an mbuf before returning, so one buffer is enough and small writes still coalesce into MTU-sized packets.
  • Clear pending state on disconnect, unsubscribe, and deinit(), so a failed packet cannot reach a later connection and a queued retry cannot outlive the object.

The commit message has the full reasoning and the per-path detail.

The unused second outgoing buffer

All the ports currently allocate two outgoing buffers. After this PR the second buffer is unused on espressif, though still allocated. This is noted in three TODO comments. The second buffer is allocated on the heap for user code, and in static RAM for the BLE workflow. To drop the static allocation, a port needs to be able to declare how many outgoing buffers it wants. The nordic SoftDevice retains the caller's buffer until TX completes and genuinely needs two buffers.

This would be a cross-port change, so it is left for a separate PR.

Testing

Tested on a Metro ESP32-S3 with the web editor over BLE on Linux. These scenarios now all work:

  • long directory listings now complete, including after fifteen repeated reloads that churn the heap
  • a multi-kilobyte file write works
  • REPL bursts of 200 and 2000 lines work
  • disconnecting the connection manually mid-REPL-burst, followed by a reconnect, works

A burst of outgoing packets (e.g., a BLE workflow directory listing or
long REPL output) exhausts NimBLE's mbuf pool, which drains only as fast
as the radio can send. So the `BLE_HS_ENOMEM` error only means the mbuf
pool is temporarily empty. But it can stay that way for seconds as the
data is sent. `queue_next_write()` mishandled it and lost data.

Problems:

- On a failed notify or indicate it restored `pending_size` for a retry,
  then fell through to an unconditional `pending_size = 0` that
  discarded the data anyway.
- The retry could not have worked regardless:
  `ble_gatts_notify_custom()` raises `BLE_GAP_EVENT_NOTIFY_TX`
  synchronously, inside the failing call, and a failed send gets no
  later completion event to prompt another attempt.
- A failed client write set `packet_queued` even though a failed
  `ble_gattc_write()` frees its proc without ever calling the callback,
  wedging the buffer; a failed notify flipped `pending_index`, which it
  never flips on success, corrupting the index and pointing
  single-buffer `PacketBuffer`s at a NULL second buffer.

Fix by reworking the send path:

- `queue_next_write()` now claims the pending buffer under the
  interrupt- disabled critical section (the `nimble_host` task is pinned
  to the same core, so this excludes it) by setting `send_in_progress`,
  which also makes the synchronous `NOTIFY_TX` event and competing send
  attempts no-ops. On `BLE_HS_ENOMEM` the data is kept for retry; on
  real errors it is dropped as before.
- Every NimBLE send call copies the flat buffer into an mbuf before
  returning, so the buffer is reusable immediately and the second
  outgoing buffer and index flipping are unnecessary. Writes coalesce
  into the single pending buffer while a packet awaits its completion
  event, or while waiting for mbufs, so 4-byte filename chunks still
  merge into MTU-sized packets.
- The wait loops in `write()` and `flush()` retry the send inline. They
  may run inside a background callback (the BLE workflow does this),
  where `RUN_BACKGROUND_TASKS` cannot re-enter background callbacks to
  help.
- A background callback retries trailing data, such as a directory
  listing's final entry, that no later write or flush would push out. It
  reschedules itself while still out of mbufs and stops on disconnect or
  deinit.
- `packet_queued` is set before submitting a write with response or an
  indicate and cleared by the completion event, so a completion that
  arrives while the submitting task is still preempted is not lost.
- Disconnect and unsubscribe discard pending data so that stale data is
  not sent into a later connection or subscription.
- Retries are paced: after a failure, skip further attempts for 10 ms,
  since the pool refills only a few packets per connection interval and
  attempting sooner just burns CPU. Successful and hard-failed sends
  reset the pacing.

The now-unused second outgoing buffer stays allocated, with TODO
comments recording that NimBLE's copy-on-send is what makes it
unnecessary here and that dropping the shared static allocation needs a
way for a port to declare how many outgoing buffers it wants. The nordic
SoftDevice retains the caller's buffer until TX completes and needs
both.

The visible symptom was a web workflow directory listing of 30+ files
losing its final entry and the terminator that marks the end of the
listing, which left the web editor's file dialog spinning forever.

Tested on a Metro ESP32-S3 with the web editor over BLE: listings
complete, including after fifteen repeated reloads that churn the heap;
a multi-kilobyte file write, REPL bursts of 2000 lines, and a disconnect
mid-burst followed by a reconnect all behave.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dhalbert

dhalbert commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Zephyr test failure is the ongoing saved word failure, unrelated to this.

@dhalbert
dhalbert requested a review from tannewt September 1, 2026 21:32

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A little wordy but ok.

@dhalbert
dhalbert merged commit 243f0f6 into adafruit:main Sep 1, 2026
25 of 27 checks passed
@dhalbert
dhalbert deleted the espressif-packetbuffer-redesign branch September 1, 2026 23:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants