Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
94 changes: 91 additions & 3 deletions src/corebluetooth/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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};
Expand All @@ -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;

Expand Down Expand Up @@ -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<u8>,
Expand Down Expand Up @@ -275,6 +281,8 @@ struct PeripheralInternal {
pub services_discovered_future_state: VecDeque<CoreBluetoothReplyStateShared>,
pub read_rssi_future_state: VecDeque<CoreBluetoothReplyStateShared>,
pub write_without_response_queue: VecDeque<PendingWriteWithoutResponse>,
#[cfg(all(target_os = "ios", feature = "ios-write-without-response-fallback"))]
write_gate: WriteGate,
}

impl Debug for PeripheralInternal {
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -1356,19 +1366,32 @@ 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),
&characteristic.characteristic,
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,
Expand All @@ -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;
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2028,7 +2113,10 @@ pub fn run_corebluetooth_thread(
let (sender, receiver) = mpsc::channel::<CoreBluetoothMessage>(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 {
Expand Down
4 changes: 4 additions & 0 deletions src/corebluetooth/internal/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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],
Expand Down
62 changes: 62 additions & 0 deletions src/corebluetooth/ios_write_policy.rs
Original file line number Diff line number Diff line change
@@ -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<Instant>,
}

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);
}
}
6 changes: 6 additions & 0 deletions src/corebluetooth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading