Skip to content

host: do not deinit ble_hs_timer while its event may be queued (#1184) - #1185

Open
rfordinal wants to merge 1 commit into
h2zero:masterfrom
rfordinal:fix/ble-hs-timer-callout-deinit
Open

host: do not deinit ble_hs_timer while its event may be queued (#1184)#1185
rfordinal wants to merge 1 commit into
h2zero:masterfrom
rfordinal:fix/ble-hs-timer-callout-deinit

Conversation

@rfordinal

@rfordinal rfordinal commented Sep 4, 2026

Copy link
Copy Markdown

Fixes #1184. One line removed. This is the PR offered in that issue.

The crash

Reproduced twice on real hardware, ESP32-S3, from two independent coredumps
(2026-09-01 and 2026-09-04), both while the application called
NimBLEDevice::deinit(true):

Crashed task: 'nimble_host'
exccause  0x14 (InstFetchProhibitedCause)
excvaddr  0x0
pc        0x0
a0        0x82053472   -> NimBLEDevice::host_task+6
a10       0x3fca5878   -> ble_hs_timer + 8

pc = 0 with InstFetchProhibited is a call through a NULL function pointer.
The faulting instruction is in nimble_port_run():

0x420638da:  l32i   a8, a10, 4     ; a8 = ev->fn
0x420638dd:  callx8 a8             ; a8 == 0

a10 is the dequeued event, and it resolves to ble_hs_timer + 8, the ev
member of the host's own timer callout. So ble_hs_timer.ev.fn was NULL when
the host task ran the event.

In the second dump a10 holds the identical value and the code registers are
offset by a constant, so it is the same event and the same call site.

Why it happens

  1. ble_hs_stop_begin() (ble_hs_stop.c) sets
    ble_hs_enabled_state = BLE_HS_ENABLED_STATE_STOPPING, then calls
    ble_hs_timer_resched() on the next line.
  2. ble_hs_is_enabled() returns true only for _ON, so it is now false.
  3. ble_hs_timer_reset() therefore takes the disabled branch and calls
    ble_npl_callout_stop() and ble_npl_callout_deinit().
  4. npl_freertos_callout_deinit() does ble_npl_event_deinit(&co->ev) and then
    memset(co, 0, sizeof(struct ble_npl_callout)). ev.fn is now NULL.
  5. If the timer had already expired, its ev is already queued on
    g_eventq_dflt. The memset does not remove it. nimble_port_run() dequeues
    it and calls ev->fn.

ble_npl_eventq_remove() exists in that port and is what step 4 would have to
call. Removing the deinit() is the smaller fix, and it matches what the other
two trees already do.

Why removing it leaks nothing

ble_hs_deinit() already ends with ble_npl_callout_deinit(&ble_hs_timer).
That runs after the host has stopped and the queue is drained, which is the
right time. The call in ble_hs_timer_reset() is redundant as well as harmful.

Comparison with the other trees

tree ble_hs_timer_reset() when not enabled
apache/mynewt-nimble master ble_npl_callout_stop() only
espressif/esp-nimble master ble_npl_callout_stop() only
espressif/esp-nimble @ f566133, e3cbdc0, 70439dd stop() and deinit()
h2zero/NimBLE-Arduino master stop() and deinit()

The deinit() came from esp-nimble, espressif has since dropped it, and this
tree still carries the older snapshot.

Testing

The removal itself is not yet run on hardware. What is verified is the fault, the
faulting instruction and the event identity, from two coredumps on ESP32-S3
(NimBLE-Arduino 2.5.1, ESP-IDF 5.5.2), plus the five-step chain read off the
vendored source. We are carrying this exact commit in a pinned fork and will
report back once it has soak-tested on our boards.

Happy to adjust the shape of the fix if you would rather remove the queued event
than skip the deinit.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved Bluetooth host timer handling when the host is disabled, allowing the timer to be reused when the host is re-enabled.

ble_hs_stop_begin() sets ble_hs_enabled_state to STOPPING and then calls
ble_hs_timer_resched() on the next line. ble_hs_is_enabled() only returns true
for _ON, so ble_hs_timer_reset() takes the disabled branch and calls
ble_npl_callout_deinit(), which on the FreeRTOS port runs
ble_npl_event_deinit(&co->ev) and memsets the callout. That clears ev.fn.

If the timer had already expired, its ev is already on g_eventq_dflt. The
memset does not remove it from the queue, so nimble_port_run() dequeues it and
calls a NULL ev->fn. On Xtensa this is a CPU exception with pc 0 and the host
task is gone.

Removing the call leaks nothing: ble_hs_deinit() already ends with
ble_npl_callout_deinit(&ble_hs_timer), which runs after the host has stopped
and the queue is drained. Both apache/mynewt-nimble and espressif/esp-nimble
call only ble_npl_callout_stop() here.

Fixes h2zero#1184
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 87f31e28-b6ea-49da-864c-c9df572b354b

📥 Commits

Reviewing files that changed from the base of the PR and between 2c487b7 and 700c360.

📒 Files selected for processing (1)
  • src/nimble/nimble/host/src/ble_hs.c
💤 Files with no reviewable changes (1)
  • src/nimble/nimble/host/src/ble_hs.c

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Host timer lifecycle

Layer / File(s) Summary
Preserve callout initialization
src/nimble/nimble/host/src/ble_hs.c
When the host is disabled, ble_hs_timer_reset stops the callout without deinitializing it, arrr. This keeps the timer available for later reuse.

Estimated code review effort: 1 (Trivial) | ~2 minutes

Merge Risk: ⚪ Minimal · up to 700c3

The change preserves the timer callback until shutdown cleanup, preventing queued timer events from invoking a cleared callback. No remaining merge-readiness risk is identified.

Poem

The timer stops, but stays afloat,
No cleared event shall miss its boat.
The host may sail and start anew,
With callout state still shipshape too.
One small line keeps crashes through.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main fix: preventing deinitialization of ble_hs_timer while its event may be queued. Arrr, it is concise and specific.
Linked Issues check ✅ Passed The one-line change removes callout deinitialization from the shutdown path. This prevents clearing a queued event callback and preserves final cleanup in ble_hs_deinit(), satisfying issue #1184.
Out of Scope Changes check ✅ Passed The change is limited to the timer shutdown behavior described in issue #1184. No unrelated changes are present.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

rfordinal added a commit to rfordinal/explorink that referenced this pull request Sep 4, 2026
…ueue

BUG-033 has reset the device on four real exits now. Upstream 2.5.1's
ble_hs_timer_reset() calls ble_npl_callout_deinit(&ble_hs_timer) when the host
is no longer enabled, which memsets ev.fn to NULL while that same ev may already
be queued on g_eventq_dflt. The host task dequeues it and jumps to 0.

Pins rfordinal/NimBLE-Arduino @ 63d87323: upstream 2.5.1 with that one line
removed and nothing else, so the diff to trust is one line. The pin is the
commit rather than the branch, so a moved branch cannot change what a clean
build fetches. Removing the call leaks nothing -- ble_hs_deinit() still deinits
that callout, after the host has stopped and the queue is drained.

The spec is named once as base.nimble_dep because two environments need it and
they are not on the same branch: default here on develop, t5s3pro on
release/lilygo-t5-s3-pro. Spelled out twice, one of them keeps the unpatched
library through a merge with nobody noticing.

Keeping the stack up instead was rejected on the measured 58,804 bytes end()
returns, with BLE at 86 % of the map screen's heap cost. deinit(false) is not an
option either: clearAll does not gate nimble_port_stop() or ble_hs_stop().

Offered upstream the same day as h2zero/NimBLE-Arduino#1185.

Builds clean on env:default, zero warnings. NOT run on hardware, and a race is
not disproved by one clean exit -- T-233 stays open for a counted map-exit loop.
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.

ble_hs_timer_reset() deinits the host timer callout while its event may be queued, host task then calls a NULL ev->fn

2 participants