Platform: macOS, CoreBluetooth backend. Seen on btleplug 0.12.0; the same logic is on master (f0ac6b7).
What happens: when the peripheral refuses the CCCD write that subscribe() triggers — for example with ATT error 0x0F, Insufficient Encryption — subscribe().await never returns.
Why — src/corebluetooth/central_delegate.rs, peripheral:didUpdateNotificationStateForCharacteristic:error:. CoreBluetooth reports the refusal through this callback, with error set and isNotifying left unchanged (false). The delegate routes on isNotifying() alone, so the refusal becomes CharacteristicUnsubscribed. on_characteristic_unsubscribed then pops unsubscribe_future_state, which is empty, and the subscribe future waiting in subscribe_future_state is never resolved. On master the error is now carried in the event, but it still takes the isNotifying() == false branch into the unsubscribe queue, so the hang is the same.
Observed: a raw CoreBluetooth replay against the same peripheral gets didUpdateNotificationStateFor … isNotifying=false error=CBATTErrorDomain 15 "Encryption is insufficient." 27–210 ms after setNotifyValue(true), and bluetoothd logs Received error response for write value 15. Through btleplug, subscribe() does not return; we only noticed because we wrap every call in a timeout.
A fix we are running — two commits on top of 0.12.0 in a fork:
- yonaka15/btleplug@95eccc0 — carry the error on both events. With an error present, the
isNotifying state points the opposite way (a refused subscribe leaves it false), so route with is_notifying != failed, and resolve the future with CoreBluetoothReply::Err, which subscribe() already maps to Error::RuntimeError.
- yonaka15/btleplug@943344a — a refusal for a characteristic already in the requested state lands on an empty queue. In that case, answer the request still pending in the other queue instead of dropping it.
Happy to open a PR against master if this direction looks right.
Same shape, not observed: peripheral:didWriteValueForCharacteristic:error: sends CharacteristicWritten only when error.is_none(), so a refused write-with-response looks like it would leave write() pending too.
Platform: macOS, CoreBluetooth backend. Seen on btleplug 0.12.0; the same logic is on
master(f0ac6b7).What happens: when the peripheral refuses the CCCD write that
subscribe()triggers — for example with ATT error 0x0F, Insufficient Encryption —subscribe().awaitnever returns.Why —
src/corebluetooth/central_delegate.rs,peripheral:didUpdateNotificationStateForCharacteristic:error:. CoreBluetooth reports the refusal through this callback, witherrorset andisNotifyingleft unchanged (false). The delegate routes onisNotifying()alone, so the refusal becomesCharacteristicUnsubscribed.on_characteristic_unsubscribedthen popsunsubscribe_future_state, which is empty, and the subscribe future waiting insubscribe_future_stateis never resolved. Onmasterthe error is now carried in the event, but it still takes theisNotifying() == falsebranch into the unsubscribe queue, so the hang is the same.Observed: a raw CoreBluetooth replay against the same peripheral gets
didUpdateNotificationStateFor … isNotifying=false error=CBATTErrorDomain 15 "Encryption is insufficient."27–210 ms aftersetNotifyValue(true), and bluetoothd logsReceived error response for write value 15. Through btleplug,subscribe()does not return; we only noticed because we wrap every call in a timeout.A fix we are running — two commits on top of 0.12.0 in a fork:
isNotifyingstate points the opposite way (a refused subscribe leaves itfalse), so route withis_notifying != failed, and resolve the future withCoreBluetoothReply::Err, whichsubscribe()already maps toError::RuntimeError.Happy to open a PR against
masterif this direction looks right.Same shape, not observed:
peripheral:didWriteValueForCharacteristic:error:sendsCharacteristicWrittenonly whenerror.is_none(), so a refused write-with-response looks like it would leavewrite()pending too.