Summary
Applications use ble_gatts_notify_custom() to send notifications. It works while NimBLE has enough MSYS mbufs to construct and queue the complete outbound ATT packet. Under sustained traffic, however, it can return BLE_HS_ENOMEM.
When that happens:
- The notification was not queued for later transmission.
- NimBLE does not retain the operation and retry it automatically.
- The supplied mbuf is consumed, so the application must preserve the original payload elsewhere and reconstruct the mbuf for another attempt.
- Retrying immediately usually encounters the same exhausted pool.
- NimBLE provides no event indicating when enough compatible MSYS capacity has returned.
BLE_GAP_EVENT_NOTIFY_TX does not solve this problem. It reports the result of a notification attempt; it does not notify the application later when an allocation that failed with BLE_HS_ENOMEM may be retried.
HCI completed-packet events are also insufficient because they describe controller progress rather than whether the host has enough MSYS blocks to rebuild the complete ATT packet.
Applications are therefore forced to poll or run retry timers after BLE_HS_ENOMEM. This adds latency and unnecessary wakeups, and choosing an appropriate retry interval requires knowledge of the connection timing and workload.
We propose keeping ble_gatts_notify_custom() as the send API while adding an asynchronous recovery notification. After a supported outbound allocation failure, NimBLE would notify the application when enough compatible MSYS capacity may be available to call ble_gatts_notify_custom() again.
The proposed implementation has two layers:
- A portable, allocation-aware MSYS waiter API.
- A higher-level NimBLE host TX-unstalled callback.
The API names below are tentative.
Related issues and existing precedent
Issue #1674 previously proposed allowing ble_gatts_notify_custom() to block with a timeout while waiting for packet buffers.
This proposal addresses the same underlying resource-exhaustion problem with a different API:
ble_gatts_notify_custom() remains non-blocking.
- The allocator records the requirements of the failed operation.
- The application receives an asynchronous, advisory recovery callback.
- Applications retain control over task priority, deadlines, and retry policy.
- Applications that require blocking behavior can implement it by signaling a semaphore or task notification from the callback.
There is also existing TX-unstalled precedent in BLE_L2CAP_EVENT_COC_TX_UNSTALLED, but that event reports L2CAP credit recovery rather than host-side MSYS allocation recovery.
Proposed portable MSYS waiter API
typedef void os_msys_waiter_fn(void *arg);
/*
* Caller-owned waiter state, defined in os_mbuf.h.
* Applications should treat its fields as private.
*/
struct os_msys_waiter;
/* Initialize caller-owned waiter state. */
void os_msys_waiter_init(struct os_msys_waiter *waiter,
os_msys_waiter_fn *cb,
void *arg);
/*
* Arm a one-shot waiter for an allocation requiring:
*
* - first_space bytes in the first mbuf
* - payload_len bytes appended across the resulting chain
*
* Returns true if the waiter was armed.
* Returns false if enough capacity is already available and the caller
* should retry immediately.
*/
bool os_msys_waiter_arm(struct os_msys_waiter *waiter,
uint16_t first_space,
uint16_t payload_len);
/* Cancel an armed waiter. */
void os_msys_waiter_cancel(struct os_msys_waiter *waiter);
The waiter describes the complete allocation requirement rather than merely waiting for any mbuf to be freed. This matters when a packet requires multiple blocks or when MSYS contains differently sized pools.
The requirement models an allocation beginning with os_msys_get_pkthdr(0, ...), followed by appending the exact payload. first_space includes space consumed in the first block by the packet header, any user header, and reserved protocol headroom.
Race-free arming
Arming links the waiter before rechecking availability under the same critical section used by allocation and free notifications.
This closes the race where capacity becomes available between:
- The failed allocation.
- Registration of the waiter.
If compatible capacity is already available during the recheck, os_msys_waiter_arm() returns false and the caller can retry immediately.
Otherwise, the waiter remains armed and receives at most one callback.
Callback and fairness semantics
A waiter is one-shot and is disarmed before its callback runs. The callback may therefore rearm the same waiter.
One compatible waiter is selected per relevant capacity transition, in FIFO order. This avoids waking every stalled producer when only one operation can make progress.
The callback runs outside the allocator critical section, in the context that changed pool availability. It must not block.
Cancellation removes an armed waiter, but it is not a synchronization fence against a callback that has already been claimed for invocation.
Proposed NimBLE host callback
Most applications would not use the low-level MSYS waiter directly. The NimBLE host would expose an optional callback through ble_hs_cfg:
typedef void ble_hs_tx_unstalled_fn(void *arg);
struct ble_hs_cfg {
/* Existing configuration fields... */
ble_hs_tx_unstalled_fn *tx_unstalled_cb;
void *tx_unstalled_arg;
};
When a supported outbound allocation fails, the host arms one shared MSYS waiter using the complete allocation requirements of the failed operation.
Once compatible capacity is available, the waiter posts a coalesced event to the NimBLE host event queue. The application callback is invoked from that event rather than directly from allocator context.
Advisory contract
The callback is advisory.
It means that the failed allocation may now succeed, not that capacity has been reserved. Another allocator may consume the available buffers before the application retries, so another BLE_HS_ENOMEM remains valid.
Applications should schedule their normal TX worker rather than transmit synchronously from the callback.
The callback is distinct from:
BLE_GAP_EVENT_NOTIFY_TX, which reports a particular notification attempt.
- HCI completed-packet events, which report controller progress.
BLE_L2CAP_EVENT_COC_TX_UNSTALLED, which reports L2CAP credit recovery.
This proposal reports recovery from a supported host-side MSYS allocation failure.
Example application usage
The application retains its original payload in an application-owned queue until the notification succeeds.
Its normal TX worker constructs an mbuf and calls ble_gatts_notify_custom():
static void
tx_worker_run(struct app_tx *tx)
{
const struct app_tx_item *item;
struct os_mbuf *om;
int rc;
while ((item = app_tx_queue_peek(tx)) != NULL) {
om = ble_hs_mbuf_from_flat(item->data, item->length);
if (om == NULL) {
/*
* On a supported outbound allocation path, NimBLE arms the
* host MSYS waiter.
*
* Keep the application payload queued. A slow fallback timer
* can remain armed for unsupported failures or another race,
* while the TX-unstalled callback can wake us immediately.
*/
app_tx_schedule_fallback_retry(tx);
return;
}
rc = ble_gatts_notify_custom(item->conn_handle,
item->attr_handle,
om);
/*
* ble_gatts_notify_custom() consumes om regardless of the result.
* The original application payload remains queued so the mbuf can
* be reconstructed if a retry is required.
*/
if (rc == BLE_HS_ENOMEM) {
/*
* A supported allocation failure arms recovery. Retain a
* slower fallback retry because not every BLE_HS_ENOMEM path
* is necessarily covered by the initial implementation.
*/
app_tx_schedule_fallback_retry(tx);
return;
}
if (rc != 0) {
app_tx_queue_fail_current(tx, rc);
continue;
}
app_tx_cancel_fallback_retry(tx);
app_tx_queue_remove_current(tx);
}
}
The TX-unstalled callback only wakes or schedules the application’s normal worker:
static void
tx_unstalled(void *arg)
{
struct app_tx *tx = arg;
/*
* Do not transmit synchronously from this callback.
*
* Coalesce the signal with the application's normal worker or event
* mechanism. The worker can clear its current backoff and retry.
*/
app_tx_worker_schedule(tx);
}
The callback is registered during host configuration:
ble_hs_cfg.tx_unstalled_cb = tx_unstalled;
ble_hs_cfg.tx_unstalled_arg = &app_tx;
This keeps the following policy under application control:
- Task priority.
- Application queue ownership.
- Retry deadlines.
- Timeout behavior.
- Coalescing multiple TX producers.
- Whether to retain a fallback timer.
Optional blocking behavior
An application that wants blocking behavior can implement it by having the callback signal a semaphore or task notification:
static void
tx_unstalled(void *arg)
{
struct app_tx *tx = arg;
semaphore_release(&tx->retry_sem);
}
The sending task can then wait with its own timeout:
rc = ble_gatts_notify_custom(conn_handle, attr_handle, om);
if (rc == BLE_HS_ENOMEM) {
semaphore_take(&tx->retry_sem, retry_timeout);
}
This provides the blocking behavior discussed in #1674 without requiring ble_gatts_notify_custom() itself to block.
It also avoids blocking when the API is called from a host callback or another context where waiting could deadlock the stack.
Supported failure scope
The proposed implementation deliberately arms recovery only for failures where NimBLE can model the complete retry requirement.
The initial scope includes:
- Outbound payload creation through
ble_hs_mbuf_from_flat().
- API-owned notification allocation when the caller passes no custom mbuf.
- API-owned indication allocation when the caller passes no custom mbuf.
- Allocation failures while extending the complete outbound payload chain.
The waiter accounts for:
- The packet header.
- Any user header.
- Reserved ATT and L2CAP leading space.
- The complete payload length across all required mbuf blocks.
- The MSYS pool that
os_msys_get_pkthdr(0, ...) would actually select.
The feature does not promise a callback for every BLE_HS_ENOMEM.
Examples of failures that may remain outside the initial contract include:
- A header-only allocation failure for an arbitrary caller-provided mbuf, because one available header block does not prove that the caller’s complete operation is retryable.
- Internal inbound mbuf conversions, which should not wake an outbound transmitter.
- Complex multiple-notification batching failures where the complete future allocation requirement cannot be represented by one waiter.
These unsupported failures continue to return BLE_HS_ENOMEM normally. The API documentation would explicitly state which failures can arm the recovery callback.
ATT headroom interaction
Standard notification, indication, and multiple-notification payload mbufs already reserve ATT headroom.
The prototype uses that reserved space for the ATT command header when possible. This avoids allocating a separate header mbuf and permits a standard send to proceed even when there are no spare MSYS blocks beyond the payload chain itself.
Arbitrary caller-provided mbufs without sufficient compatible headroom retain the existing separate-header allocation fallback and ownership behavior.
Failure of that fallback does not report the overall application operation as recoverable merely because one header block later becomes available.
This ATT headroom work can be separated from the core waiter and callback change if it would make review easier.
Host lifecycle
Pending recovery state must not escape the host lifecycle that created it.
The host therefore cancels the shared MSYS waiter and removes any queued recovery event during:
- Host reset.
- Host stop.
- Host reinitialization.
- Host deinitialization.
The callback is active only while the host is:
- Initialized.
- Enabled.
- Synchronized with the controller.
- Configured with a non-
NULL tx_unstalled_cb.
This prevents stale recovery callbacks from a previous host instance or synchronization cycle.
Prototype and test coverage
We have a prototype with tests covering:
- The allocation-failure-to-registration race.
- Immediate retry when capacity is already available.
- Exact multi-block capacity requirements.
- Selection among multiple differently sized MSYS pools.
- A smaller pool becoming usable after the larger pool is exhausted.
- One-shot callback behavior.
- Cancellation and callback rearming.
- FIFO waiter selection.
- Avoidance of thundering-herd wakeups.
- Host-event coalescing.
- Notification and indication recovery.
- Full chained-payload recovery.
- Reset, stop, reinitialization, and deinitialization cleanup.
- Internal allocation paths that must remain silent.
- Failure paths that intentionally do not generate callbacks.
- ATT command placement within reserved payload headroom.
- Arbitrary caller-mbuf fallback compatibility and ownership.
Questions for maintainers
Before preparing a pull request, we would appreciate feedback on the intended API boundary:
- Should NimBLE expose both the portable MSYS waiter and the host callback, or only the host-level API?
- Is a global advisory host callback appropriate, or should recovery be reported per connection or per operation?
- Is it acceptable for the initial implementation to cover only allocation failures whose complete retry requirements can be modeled?
- Should
ble_hs_mbuf_from_flat() implicitly arm outbound recovery, or should there be an explicit TX-specific helper?
- Should the ATT headroom and multiple-notification ownership changes be submitted separately from the core waiter and callback?
- Are FIFO, one-waiter-per-transition semantics appropriate for avoiding thundering-herd wakeups?
We can prepare the implementation as a PR once there is agreement on the public API and initial scope.
Summary
Applications use
ble_gatts_notify_custom()to send notifications. It works while NimBLE has enough MSYS mbufs to construct and queue the complete outbound ATT packet. Under sustained traffic, however, it can returnBLE_HS_ENOMEM.When that happens:
BLE_GAP_EVENT_NOTIFY_TXdoes not solve this problem. It reports the result of a notification attempt; it does not notify the application later when an allocation that failed withBLE_HS_ENOMEMmay be retried.HCI completed-packet events are also insufficient because they describe controller progress rather than whether the host has enough MSYS blocks to rebuild the complete ATT packet.
Applications are therefore forced to poll or run retry timers after
BLE_HS_ENOMEM. This adds latency and unnecessary wakeups, and choosing an appropriate retry interval requires knowledge of the connection timing and workload.We propose keeping
ble_gatts_notify_custom()as the send API while adding an asynchronous recovery notification. After a supported outbound allocation failure, NimBLE would notify the application when enough compatible MSYS capacity may be available to callble_gatts_notify_custom()again.The proposed implementation has two layers:
The API names below are tentative.
Related issues and existing precedent
Issue #1674 previously proposed allowing
ble_gatts_notify_custom()to block with a timeout while waiting for packet buffers.This proposal addresses the same underlying resource-exhaustion problem with a different API:
ble_gatts_notify_custom()remains non-blocking.There is also existing TX-unstalled precedent in
BLE_L2CAP_EVENT_COC_TX_UNSTALLED, but that event reports L2CAP credit recovery rather than host-side MSYS allocation recovery.Proposed portable MSYS waiter API
The waiter describes the complete allocation requirement rather than merely waiting for any mbuf to be freed. This matters when a packet requires multiple blocks or when MSYS contains differently sized pools.
The requirement models an allocation beginning with
os_msys_get_pkthdr(0, ...), followed by appending the exact payload.first_spaceincludes space consumed in the first block by the packet header, any user header, and reserved protocol headroom.Race-free arming
Arming links the waiter before rechecking availability under the same critical section used by allocation and free notifications.
This closes the race where capacity becomes available between:
If compatible capacity is already available during the recheck,
os_msys_waiter_arm()returnsfalseand the caller can retry immediately.Otherwise, the waiter remains armed and receives at most one callback.
Callback and fairness semantics
A waiter is one-shot and is disarmed before its callback runs. The callback may therefore rearm the same waiter.
One compatible waiter is selected per relevant capacity transition, in FIFO order. This avoids waking every stalled producer when only one operation can make progress.
The callback runs outside the allocator critical section, in the context that changed pool availability. It must not block.
Cancellation removes an armed waiter, but it is not a synchronization fence against a callback that has already been claimed for invocation.
Proposed NimBLE host callback
Most applications would not use the low-level MSYS waiter directly. The NimBLE host would expose an optional callback through
ble_hs_cfg:When a supported outbound allocation fails, the host arms one shared MSYS waiter using the complete allocation requirements of the failed operation.
Once compatible capacity is available, the waiter posts a coalesced event to the NimBLE host event queue. The application callback is invoked from that event rather than directly from allocator context.
Advisory contract
The callback is advisory.
It means that the failed allocation may now succeed, not that capacity has been reserved. Another allocator may consume the available buffers before the application retries, so another
BLE_HS_ENOMEMremains valid.Applications should schedule their normal TX worker rather than transmit synchronously from the callback.
The callback is distinct from:
BLE_GAP_EVENT_NOTIFY_TX, which reports a particular notification attempt.BLE_L2CAP_EVENT_COC_TX_UNSTALLED, which reports L2CAP credit recovery.This proposal reports recovery from a supported host-side MSYS allocation failure.
Example application usage
The application retains its original payload in an application-owned queue until the notification succeeds.
Its normal TX worker constructs an mbuf and calls
ble_gatts_notify_custom():The TX-unstalled callback only wakes or schedules the application’s normal worker:
The callback is registered during host configuration:
This keeps the following policy under application control:
Optional blocking behavior
An application that wants blocking behavior can implement it by having the callback signal a semaphore or task notification:
The sending task can then wait with its own timeout:
This provides the blocking behavior discussed in #1674 without requiring
ble_gatts_notify_custom()itself to block.It also avoids blocking when the API is called from a host callback or another context where waiting could deadlock the stack.
Supported failure scope
The proposed implementation deliberately arms recovery only for failures where NimBLE can model the complete retry requirement.
The initial scope includes:
ble_hs_mbuf_from_flat().The waiter accounts for:
os_msys_get_pkthdr(0, ...)would actually select.The feature does not promise a callback for every
BLE_HS_ENOMEM.Examples of failures that may remain outside the initial contract include:
These unsupported failures continue to return
BLE_HS_ENOMEMnormally. The API documentation would explicitly state which failures can arm the recovery callback.ATT headroom interaction
Standard notification, indication, and multiple-notification payload mbufs already reserve ATT headroom.
The prototype uses that reserved space for the ATT command header when possible. This avoids allocating a separate header mbuf and permits a standard send to proceed even when there are no spare MSYS blocks beyond the payload chain itself.
Arbitrary caller-provided mbufs without sufficient compatible headroom retain the existing separate-header allocation fallback and ownership behavior.
Failure of that fallback does not report the overall application operation as recoverable merely because one header block later becomes available.
This ATT headroom work can be separated from the core waiter and callback change if it would make review easier.
Host lifecycle
Pending recovery state must not escape the host lifecycle that created it.
The host therefore cancels the shared MSYS waiter and removes any queued recovery event during:
The callback is active only while the host is:
NULLtx_unstalled_cb.This prevents stale recovery callbacks from a previous host instance or synchronization cycle.
Prototype and test coverage
We have a prototype with tests covering:
Questions for maintainers
Before preparing a pull request, we would appreciate feedback on the intended API boundary:
ble_hs_mbuf_from_flat()implicitly arm outbound recovery, or should there be an explicit TX-specific helper?We can prepare the implementation as a PR once there is agreement on the public API and initial scope.