From 2c8b4991376f1d6225174c1dafe554f34ae3351a Mon Sep 17 00:00:00 2001 From: Chris Morgan Date: Thu, 16 Jul 2026 08:21:05 -0400 Subject: [PATCH 1/2] corebluetooth - store negotiated MTU after service discovery CoreBluetooth's maximum write length is not reliably negotiated in the initial connection callback. Infer and publish the ATT MTU after service discovery, while resetting the exposed MTU to the default for each new connection. Rename the Rust integration test and its Android JNI/Kotlin entry points from "after connection" to "after service discovery". The shared helper discovers services before reading the MTU, so the old name overstated connect-only coverage; the JNI export and Kotlin declarations must match the renamed shared test. --- src/corebluetooth/internal.rs | 31 +++++++++++++++++-- src/corebluetooth/peripheral.rs | 8 ++++- tests/android/rust/src/lib.rs | 4 +-- .../btleplug/test/BleIntegrationTest.kt | 2 +- .../btleplug/test/NativeTests.kt | 2 +- tests/common/test_cases.rs | 2 +- tests/test_mtu_after_connection.rs | 7 ----- tests/test_mtu_after_service_discovery.rs | 7 +++++ 8 files changed, 48 insertions(+), 15 deletions(-) delete mode 100644 tests/test_mtu_after_connection.rs create mode 100644 tests/test_mtu_after_service_discovery.rs diff --git a/src/corebluetooth/internal.rs b/src/corebluetooth/internal.rs index c0f42aa6..2568654e 100644 --- a/src/corebluetooth/internal.rs +++ b/src/corebluetooth/internal.rs @@ -42,6 +42,21 @@ use std::{ use tokio::runtime; use uuid::Uuid; +/// ATT Write Command PDUs reserve one byte for the opcode and two bytes for +/// the attribute handle (Bluetooth Core Specification, Vol 3, Part F, 3.4.5.3). +const ATT_WRITE_COMMAND_HEADER_LEN: usize = 3; + +fn maximum_write_value_length_to_att_mtu(maximum_write_value_length: usize) -> Result { + maximum_write_value_length + .checked_add(ATT_WRITE_COMMAND_HEADER_LEN) + .and_then(|mtu| u16::try_from(mtu).ok()) + .ok_or_else(|| { + format!( + "CoreBluetooth maximum write value length {maximum_write_value_length} cannot be represented as a u16 ATT MTU" + ) + }) +} + struct DescriptorInternal { pub descriptor: Retained, pub uuid: Uuid, @@ -159,7 +174,7 @@ pub enum CoreBluetoothReply { ReadResult(Vec), ReadRssi(i16), Connected, - ServicesDiscovered(BTreeSet), + ServicesDiscovered(BTreeSet, u16), State(CBPeripheralState), Ok, Err(String), @@ -346,12 +361,24 @@ impl PeripheralInternal { .collect(), }) .collect(); + // CoreBluetooth exposes the maximum characteristic value length for + // a write, not the ATT MTU. Sample it after discovery, then account + // for the ATT Write Command header to infer the full ATT MTU. + let maximum_write_value_length = unsafe { + self.peripheral.maximumWriteValueLengthForType( + CBCharacteristicWriteType::CBCharacteristicWriteWithoutResponse, + ) + }; + let reply = match maximum_write_value_length_to_att_mtu(maximum_write_value_length) { + Ok(mtu) => CoreBluetoothReply::ServicesDiscovered(services, mtu), + Err(error) => CoreBluetoothReply::Err(error), + }; self.services_discovered_future_state .take() .unwrap() .lock() .unwrap() - .set_reply(CoreBluetoothReply::ServicesDiscovered(services)); + .set_reply(reply); } } diff --git a/src/corebluetooth/peripheral.rs b/src/corebluetooth/peripheral.rs index 229daaa6..5936bf76 100644 --- a/src/corebluetooth/peripheral.rs +++ b/src/corebluetooth/peripheral.rs @@ -288,6 +288,9 @@ impl api::Peripheral for Peripheral { .await?; match fut.await { CoreBluetoothReply::Connected => { + self.shared + .mtu + .store(api::DEFAULT_MTU_SIZE, std::sync::atomic::Ordering::Relaxed); self.shared .emit_event(CentralEvent::DeviceConnected(self.shared.uuid.into())); } @@ -330,8 +333,11 @@ impl api::Peripheral for Peripheral { }) .await?; match fut.await { - CoreBluetoothReply::ServicesDiscovered(services) => { + CoreBluetoothReply::ServicesDiscovered(services, mtu) => { *(self.shared.services.lock().map_err(Into::::into)?) = services; + self.shared + .mtu + .store(mtu, std::sync::atomic::Ordering::Relaxed); return Ok(()); } CoreBluetoothReply::Err(msg) => return Err(Error::RuntimeError(msg)), diff --git a/tests/android/rust/src/lib.rs b/tests/android/rust/src/lib.rs index f56d58aa..cb2c6649 100644 --- a/tests/android/rust/src/lib.rs +++ b/tests/android/rust/src/lib.rs @@ -208,8 +208,8 @@ jni_test!( test_cases::test_descriptor_discovery ); jni_test!( - Java_com_nonpolynomial_btleplug_test_NativeTests_testMtuAfterConnection, - test_cases::test_mtu_after_connection + Java_com_nonpolynomial_btleplug_test_NativeTests_testMtuAfterServiceDiscovery, + test_cases::test_mtu_after_service_discovery ); jni_test!( Java_com_nonpolynomial_btleplug_test_NativeTests_testReadRssi, diff --git a/tests/android/src/androidTest/kotlin/com/nonpolynomial/btleplug/test/BleIntegrationTest.kt b/tests/android/src/androidTest/kotlin/com/nonpolynomial/btleplug/test/BleIntegrationTest.kt index ccb5dd7f..8967b95d 100644 --- a/tests/android/src/androidTest/kotlin/com/nonpolynomial/btleplug/test/BleIntegrationTest.kt +++ b/tests/android/src/androidTest/kotlin/com/nonpolynomial/btleplug/test/BleIntegrationTest.kt @@ -97,7 +97,7 @@ class BleIntegrationTest { @Test fun testDescriptorDiscovery() = NativeTests.testDescriptorDiscovery() // ── Device Info ───────────────────────────────────────────────── - @Test fun testMtuAfterConnection() = NativeTests.testMtuAfterConnection() + @Test fun testMtuAfterServiceDiscovery() = NativeTests.testMtuAfterServiceDiscovery() @Test fun testReadRssi() = NativeTests.testReadRssi() @Test fun testPropertiesContainPeripheralInfo() = NativeTests.testPropertiesContainPeripheralInfo() @Test fun testConnectionParameters() = NativeTests.testConnectionParameters() diff --git a/tests/android/src/main/kotlin/com/nonpolynomial/btleplug/test/NativeTests.kt b/tests/android/src/main/kotlin/com/nonpolynomial/btleplug/test/NativeTests.kt index ab43ee4e..466b32d8 100644 --- a/tests/android/src/main/kotlin/com/nonpolynomial/btleplug/test/NativeTests.kt +++ b/tests/android/src/main/kotlin/com/nonpolynomial/btleplug/test/NativeTests.kt @@ -39,7 +39,7 @@ object NativeTests { external fun testDescriptorDiscovery() // Device Info - external fun testMtuAfterConnection() + external fun testMtuAfterServiceDiscovery() external fun testReadRssi() external fun testPropertiesContainPeripheralInfo() external fun testConnectionParameters() diff --git a/tests/common/test_cases.rs b/tests/common/test_cases.rs index 5915f5e4..fa512e50 100644 --- a/tests/common/test_cases.rs +++ b/tests/common/test_cases.rs @@ -581,7 +581,7 @@ pub async fn test_descriptor_discovery() { // ── Device Info ───────────────────────────────────────────────────── -pub async fn test_mtu_after_connection() { +pub async fn test_mtu_after_service_discovery() { let peripheral = peripheral_finder::find_and_connect().await; let mtu = peripheral.mtu(); assert!( diff --git a/tests/test_mtu_after_connection.rs b/tests/test_mtu_after_connection.rs deleted file mode 100644 index 50f549b1..00000000 --- a/tests/test_mtu_after_connection.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod common; - -#[tokio::test] -#[ignore = "requires BLE test peripheral"] -async fn test_mtu_after_connection() { - common::test_cases::test_mtu_after_connection().await; -} diff --git a/tests/test_mtu_after_service_discovery.rs b/tests/test_mtu_after_service_discovery.rs new file mode 100644 index 00000000..603ea1eb --- /dev/null +++ b/tests/test_mtu_after_service_discovery.rs @@ -0,0 +1,7 @@ +mod common; + +#[tokio::test] +#[ignore = "requires BLE test peripheral"] +async fn test_mtu_after_service_discovery() { + common::test_cases::test_mtu_after_service_discovery().await; +} From 9354bc206611f2bb297fd164b527b9145c235f99 Mon Sep 17 00:00:00 2001 From: Chris Morgan Date: Thu, 16 Jul 2026 08:22:29 -0400 Subject: [PATCH 2/2] tests - require negotiated CoreBluetooth MTU --- tests/common/test_cases.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/common/test_cases.rs b/tests/common/test_cases.rs index fa512e50..22e2c6a8 100644 --- a/tests/common/test_cases.rs +++ b/tests/common/test_cases.rs @@ -584,6 +584,15 @@ pub async fn test_descriptor_discovery() { pub async fn test_mtu_after_service_discovery() { let peripheral = peripheral_finder::find_and_connect().await; let mtu = peripheral.mtu(); + + #[cfg(any(target_os = "macos", target_os = "ios"))] + assert!( + mtu > 23, + "CoreBluetooth MTU should be negotiated above the default, got {}", + mtu + ); + + #[cfg(not(any(target_os = "macos", target_os = "ios")))] assert!( mtu >= 23, "MTU should be at least 23 (default), got {}",