deviceplug / btleplug

Rust Cross-Platform Host-Side Bluetooth LE Access Library

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Discussion: winrt Peripheral state lifetime/ownership design details

rib opened this issue · comments

Sorry this is more of a question/discussion than an issue...

While looking to use btleplug on Windows with heart rate monitors I wasn't exactly sure if I was responsible for cleaning up anything related to a peripheral if I received a disconnect event - in particular I wasn't sure if I would need to call notifications() again to get a stream for events if I were to reconnect and I wasn't sure if I would need to explicitly re-subscribe to characteristic notifications if I reconnect.

Looking at the code for windows and also comparing a little with bluez I think maybe there could be some inconsistency here at the moment, and in relation to this I saw that there's various peripheral state held under separate Arc<>s that made me wonder if there's a design requirement necessitating this that wasn't obvious to me.

With the notifications() stream; I see in the winrt backend that each time this would be called then a new mpsc channel will be created and this will be added to an internal vector held within an Arc<Mutex<>>:

    async fn notifications(&self) -> Result<Pin<Box<dyn Stream<Item = ValueNotification> + Send>>> {
        let (sender, receiver) = mpsc::unbounded();
        let mut senders = self.notification_senders.lock().unwrap();
        senders.push(sender);
        Ok(Box::pin(receiver))
    }

There's nothing that I've seen that would ever remove from that vector and while my understanding is that Peripherals themselves will persist between device disconnect/connect cycles then it seems notable that if an application were to repeatedly call notifications() they would end up with an ever-growing vector of channels.

By comparison the bluez backend delegates to a very similar api from the bluez library which return a filtered stream for events and doesn't seem to claim any kind of reference to that stream internally - it just passes it on to the application. Looking at the bluez implementation of that API it also looks like it's been carefully designed to hand out a stream that filters a central dbus connection and has a Drop implementation that will asynchronously make sure to clean up any associated internal state. So I think with the bluez backend an application should be able to call notifications() multiple times and so long as it drops previously received streams then there wouldn't be any kind of growing collection of internal state.

I was wondering if it could instead make sense for the winrt backend to instead just create a single sync::broadcast channel and clone receivers from this to hand out as a stream and thereby avoid needing to maintain an internal collection of channels/receivers? (A broadcast::Receiver implements Drop which will handle removing itself from the channel)

When considering this I also noticed that the current Peripheral imlementation is tracking several pieces of state under Arc<Mutex<>> wrappers:

pub struct Peripheral {
    device: Arc<tokio::sync::Mutex<Option<BLEDevice>>>,
    adapter: AdapterManager<Self>,
    address: BDAddr,
    properties: Arc<Mutex<Option<PeripheralProperties>>>,
    connected: Arc<AtomicBool>,
    ble_characteristics: Arc<DashMap<Uuid, BLECharacteristic>>,
    notification_senders: Arc<Mutex<Vec<UnboundedSender<ValueNotification>>>>,
}

and I was wondering what the reason for that might be, in case there's a design requirement I could be unaware of when thinking about the original issue.

I can see why there's an Arc<Mutex<>> wrapper around the current notification_senders while each each separate subscription() will end up taking a reference to the collection of mpsc channels which may be extended at any point in the future by further calls to notifications()

but I don't currently understand why there is an Arc<> around the device, properties, connected and ble_characteristics state. The Arc<> around an AtomicBool seems particularly surprising here.

I can have a look at updating this to use a broadcast channel for events if that sounds sensible here?

Would also be interested in any extra info about design requirements that help explain the heavy use of Arc<Mutex<>> wrappers for internal state here.

So just out of curiosity I tried dropping some of these Arc<>s to see the fallout and I see that they probably relate to Peripheral implementing the Clone trait and it probably makes life easier when Clone can be automatically derived if all these internal members implement Clone by virtue of being wrapped in Arc<>s. Still it seems kinda surprising at first glance and I wonder now why Peripherals need to implement Clone and maybe if that's important perhaps the winrt implementation could at least aggregate some of its inner state to avoid boxing things individually (especially the connected boolean)?

Oh, also just realised that the backend uses a futures::channel::mpsc, not using tokio, so using a tokio broadcast channel would imply also depending on the sync feature for tokio.

Ah, I also just discovered that old mpsc channels (where the receiver has been dropped) will be lazily removed from the vector via the send_notifications utility:

pub fn send_notification<T: Clone>(
    notification_senders: &Arc<Mutex<Vec<UnboundedSender<T>>>>,
    n: &T,
) {
    let mut senders = notification_senders.lock().unwrap();
    // Remove sender from the list if the other end of the channel has been dropped.
    senders.retain(|sender| sender.unbounded_send(n.clone()).is_ok());
}

I suppose I still wonder whether it could be worthwhile removing the need to manual track receiver state by using something like a broadcast channel that will clean up state via the Drop implementation of the receiver.

At least as a proof of concept this seems to work: rib@d400666

Also as a proof of concept I experimented with boxing all the shared Peripheral state into a single Arc which seems to work: rib@121251b

commented

Yup, notifications should be a tokio broadcast channel. I've been pushing for this for a while (it's what I do up in Buttplug and it works great) and will probably just make it happen myself at this point, unless you beat me to a PR (which is completely welcome!). There's no reason to do this work ourselves, tokio has it done and is far better tested.

okey cool, thanks for taking a look @qdot. I've at least made a PR that updates the winrt backend, but would have to make the corebluetooth change blind at the moment, so I've not looked at that. I think I saw the same pattern was duplicated in common code - maybe in the AdapterManager so I could perhaps also look at converting that if that also makes sense.

I think the questions I had here are resolved now, thanks