diff --git a/Cargo.toml b/Cargo.toml index 5d1ffaed..80d26c2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ name = "btleplug" path = "src/lib.rs" [features] +# Opt in to paced writes when iOS stops reporting write readiness. +ios-write-without-response-fallback = [] serde = ["uuid/serde", "serde_cr", "serde_bytes"] jni-host-tests = ["jni/invocation", "once_cell"] diff --git a/README.md b/README.md index e22779b8..8b73f135 100644 --- a/README.md +++ b/README.md @@ -209,3 +209,22 @@ BTLEPlug is covered under a BSD 3-Clause License, with some parts from Rumble/Blurmac covered under MIT/Apache dual license, and BSD 3-Clause licenses, respectively. See LICENSE.md for more info and copyright information. + +## Optional iOS write readiness fallback + +The `ios-write-without-response-fallback` Cargo feature is disabled by default. +It enables an iOS-only workaround for missing write-without-response readiness +callbacks in the background. If readiness is not reported, queued writes may be +submitted once 30 ms has elapsed since the previous submission. The first queued +write waits 30 ms when there is no previous submission. Normal ready writes keep +the existing fast path; FIFO ordering is preserved. + +This is a best-effort workaround, not a delivery guarantee: bypassing readiness +can lose data if the controller buffer is actually full. Applications should opt +in only when this tradeoff is appropriate and provide their own timeout and +message-expiration policies. Dropping the write future cancels writes still in +the fallback queue; it cannot recall writes already submitted to CoreBluetooth. +The feature does not keep an app running in the background or affect other +platforms or writes with a response. + +See [Apple's discussion of background readiness callbacks](https://developer.apple.com/forums/thread/765444). diff --git a/src/corebluetooth/internal.rs b/src/corebluetooth/internal.rs index 5c03c8c2..68934595 100644 --- a/src/corebluetooth/internal.rs +++ b/src/corebluetooth/internal.rs @@ -8,6 +8,8 @@ // For more info on handling CoreBluetooth Managers (and possibly having // multiple), see https://forums.developer.apple.com/thread/20810 +#[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] +use super::ios_write_policy::WriteGate; use super::{ central_delegate::{CentralDelegate, CentralDelegateEvent}, ffi, @@ -24,9 +26,9 @@ use crate::api::{ WriteType, }; use futures::channel::mpsc::{self, Receiver, Sender}; -use futures::select; use futures::sink::SinkExt; use futures::stream::{Fuse, StreamExt}; +use futures::{FutureExt, select}; use log::{debug, error, trace, warn}; use objc2::{AnyThread, msg_send}; use objc2::{rc::Retained, runtime::AnyObject}; @@ -44,6 +46,8 @@ use std::{ ops::Deref, thread, }; +#[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] +use std::{sync::Arc, time::Instant}; use tokio::runtime; use uuid::Uuid; @@ -194,6 +198,8 @@ impl CharacteristicInternal { } struct PendingWriteWithoutResponse { + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + queued_at: Instant, service_uuid: Uuid, characteristic_uuid: Uuid, data: Vec, @@ -275,6 +281,8 @@ struct PeripheralInternal { pub services_discovered_future_state: VecDeque, pub read_rssi_future_state: VecDeque, pub write_without_response_queue: VecDeque, + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + write_gate: WriteGate, } impl Debug for PeripheralInternal { @@ -313,6 +321,8 @@ impl PeripheralInternal { services_discovered_future_state: VecDeque::with_capacity(2), read_rssi_future_state: VecDeque::with_capacity(4), write_without_response_queue: VecDeque::new(), + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + write_gate: WriteGate::default(), } } @@ -1356,7 +1366,10 @@ impl CoreBluetoothInternal { trace!("Writing value! With kind {:?}", kind); match kind { WriteType::WithoutResponse => { - if unsafe { peripheral.peripheral.canSendWriteWithoutResponse() } { + let ready = unsafe { peripheral.peripheral.canSendWriteWithoutResponse() }; + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + let ready = ready && peripheral.write_without_response_queue.is_empty(); + if ready { unsafe { peripheral.peripheral.writeValue_forCharacteristic_type( &NSData::from_vec(data), @@ -1364,11 +1377,21 @@ impl CoreBluetoothInternal { CBCharacteristicWriteType::WithoutResponse, ); } + #[cfg(all( + target_os = "ios", + feature = "ios-write-without-response-fallback" + ))] + peripheral.write_gate.submitted(Instant::now()); fut.lock().unwrap().set_reply(CoreBluetoothReply::Ok); } else { trace!("Queueing write-without-response (peripheral not ready)"); peripheral.write_without_response_queue.push_back( PendingWriteWithoutResponse { + #[cfg(all( + target_os = "ios", + feature = "ios-write-without-response-fallback" + ))] + queued_at: Instant::now(), service_uuid, characteristic_uuid, data, @@ -1394,6 +1417,33 @@ impl CoreBluetoothInternal { fn drain_write_without_response_queue(&mut self, peripheral_uuid: Uuid) { if let Some(peripheral) = self.peripherals.get_mut(&peripheral_uuid) { while let Some(pending) = peripheral.write_without_response_queue.pop_front() { + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + { + if Arc::strong_count(&pending.fut) == 1 { + // The caller dropped its future (for example, on timeout). + continue; + } + if unsafe { peripheral.peripheral.state() } != CBPeripheralState::Connected { + pending + .fut + .lock() + .unwrap() + .set_reply(CoreBluetoothReply::Err( + "Peripheral disconnected before queued write".into(), + )); + continue; + } + if !unsafe { peripheral.peripheral.canSendWriteWithoutResponse() } + && Instant::now() < peripheral.write_gate.deadline(pending.queued_at) + { + peripheral.write_without_response_queue.push_front(pending); + break; + } + } + #[cfg(not(all( + target_os = "ios", + feature = "ios-write-without-response-fallback" + )))] if !unsafe { peripheral.peripheral.canSendWriteWithoutResponse() } { peripheral.write_without_response_queue.push_front(pending); break; @@ -1409,6 +1459,11 @@ impl CoreBluetoothInternal { CBCharacteristicWriteType::WithoutResponse, ); } + #[cfg(all( + target_os = "ios", + feature = "ios-write-without-response-fallback" + ))] + peripheral.write_gate.submitted(Instant::now()); pending .fut .lock() @@ -1761,7 +1816,37 @@ impl CoreBluetoothInternal { } async fn wait_for_message(&mut self) { + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + let deadline = self + .peripherals + .values() + .filter_map(|p| { + p.write_without_response_queue + .front() + .map(|pending| p.write_gate.deadline(pending.queued_at)) + }) + .min(); + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + let retry = async move { + match deadline { + Some(at) => tokio::time::sleep_until(at.into()).await, + None => futures::future::pending::<()>().await, + } + } + .fuse(); + #[cfg(not(all(target_os = "ios", feature = "ios-write-without-response-fallback")))] + let retry = futures::future::pending::<()>().fuse(); + futures::pin_mut!(retry); select! { + _ = retry => { + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + { + let peripherals: Vec<_> = self.peripherals.keys().copied().collect(); + for uuid in peripherals { + self.drain_write_without_response_queue(uuid); + } + } + }, delegate_msg = self.delegate_receiver.select_next_some() => { match delegate_msg { // TODO We should probably also register some sort of @@ -2028,7 +2113,10 @@ pub fn run_corebluetooth_thread( let (sender, receiver) = mpsc::channel::(256); // CoreBluetoothInternal is !Send, so we need to keep it on a single thread. thread::spawn(move || { - let runtime = runtime::Builder::new_current_thread().build().unwrap(); + let mut builder = runtime::Builder::new_current_thread(); + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + builder.enable_time(); + let runtime = builder.build().unwrap(); runtime.block_on(async move { let mut cbi = CoreBluetoothInternal::new(receiver, event_sender); loop { diff --git a/src/corebluetooth/internal/tests.rs b/src/corebluetooth/internal/tests.rs index d109bcd3..393f7fc9 100644 --- a/src/corebluetooth/internal/tests.rs +++ b/src/corebluetooth/internal/tests.rs @@ -2028,6 +2028,8 @@ async fn remove_services_drains_matching_write_without_response_queue() { internal .write_without_response_queue .push_back(PendingWriteWithoutResponse { + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + queued_at: Instant::now(), service_uuid: removed_service_uuid, characteristic_uuid, data: vec![1], @@ -2036,6 +2038,8 @@ async fn remove_services_drains_matching_write_without_response_queue() { internal .write_without_response_queue .push_back(PendingWriteWithoutResponse { + #[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))] + queued_at: Instant::now(), service_uuid: kept_service_uuid, characteristic_uuid, data: vec![2], diff --git a/src/corebluetooth/ios_write_policy.rs b/src/corebluetooth/ios_write_policy.rs new file mode 100644 index 00000000..80c87526 --- /dev/null +++ b/src/corebluetooth/ios_write_policy.rs @@ -0,0 +1,62 @@ +use std::time::{Duration, Instant}; + +// iOS may stop reporting write readiness in the background. Space fallback +// submissions to avoid flushing a queued batch when the callback is missing. +const WRITE_INTERVAL: Duration = Duration::from_millis(30); + +#[derive(Default)] +pub(super) struct WriteGate { + last_write: Option, +} + +impl WriteGate { + pub(super) fn deadline(&self, queued: Instant) -> Instant { + // Count idle time toward pacing instead of delaying every new request. + self.last_write.map_or(queued + WRITE_INTERVAL, |last| { + (last + WRITE_INTERVAL).max(queued) + }) + } + + pub(super) fn submitted(&mut self, now: Instant) { + self.last_write = Some(now); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_write_has_a_bounded_wait() { + let queued = Instant::now(); + assert_eq!( + WriteGate::default().deadline(queued), + queued + WRITE_INTERVAL + ); + } + + #[test] + fn queued_writes_remain_paced_after_each_submission() { + let mut gate = WriteGate::default(); + let queued = Instant::now(); + let first = queued + WRITE_INTERVAL; + gate.submitted(first); + assert_eq!(gate.deadline(queued), first + WRITE_INTERVAL); + let second = first + WRITE_INTERVAL; + gate.submitted(second); + assert_eq!(gate.deadline(queued), second + WRITE_INTERVAL); + } + + #[test] + fn idle_time_counts_toward_the_interval() { + let mut gate = WriteGate::default(); + let start = Instant::now(); + gate.submitted(start); + assert_eq!( + gate.deadline(start + Duration::from_millis(20)), + start + WRITE_INTERVAL + ); + let later = start + Duration::from_millis(100); + assert_eq!(gate.deadline(later), later); + } +} diff --git a/src/corebluetooth/mod.rs b/src/corebluetooth/mod.rs index 004e39bb..5623cbe2 100644 --- a/src/corebluetooth/mod.rs +++ b/src/corebluetooth/mod.rs @@ -13,3 +13,9 @@ mod internal; pub mod manager; pub mod peripheral; mod utils; + +#[cfg(all( + feature = "ios-write-without-response-fallback", + any(target_os = "ios", test) +))] +mod ios_write_policy;