Files
fips/src/transport/ble/mod.rs
T
Arjen dca938c104 feat(peer): switchover between a peer's transports, with no handshake
A peer reachable over more than one transport keeps one Noise session
and moves its traffic between transports on failure or degradation.
Implements docs/design/fips-multi-path-switchover.md §4-§10 and closes
the two-interface case in #143.

Three inner link messages next to Heartbeat: `0x52 PathProbe` and
`0x53 PathAck`, carrying a probe id, the sender's path id and a
`remote_active` bit, and `0x54 PathClose`, naming the receiver's path
id and a reason. A probe is an ordinary encrypted frame sent on a
candidate transport; the receiver, having decrypted it against the
session found by index, adds the path as `Probing`, marks it `rx_live`
and answers on that same path. The prober's receipt of the ack marks
the path `Live`, `tx_live`, and takes an RTT sample. No handshake, no
key material, no index allocation. Old nodes drop the unknown types at
debug, so a path to one stays `Probing` and never becomes eligible.

The discovery gate changes shape: a live peer beaconing on a transport
we hold no path to it over becomes a path candidate rather than being
skipped, and the heartbeat tick probes it. The active path's first
probe is small — the handshake proved it and seeded its MTU — while a
standby's discovery probes are padded to the link MTU, as is one a
minute on every path, so a medium that passes small frames and drops
large ones never proves itself. A standby the peer never answers on is
given up after eight probes; the active path never is.

Detection is per path and takes each medium's own failure signal: a
carrier edge, an unreachable-on-send (`ENETUNREACH`/`EHOSTUNREACH`), an
interface going away, or two unanswered heartbeats on a path the peer
is also silent on. Any of them marks the path `Suspect` and selection
leaves it at once, because the standby is warm: heartbeats run at
`node.path.active_heartbeat_ms` where either side sends and
`standby_heartbeat_ms` elsewhere, both stretched by the path's own
round trip so a Tor or Nym path is neither flooded nor declared dead
every round trip. A peer holding one live path is not heartbeated here
at all — selection has nothing to move to, and the link heartbeat keeps
its liveness. Soft signals (the peer's `remote_active` flipping away,
silence here while a standby hears the peer) trigger a probe, never
`Suspect`: reading them as a verdict forces both sides onto one path
and loops under a one-way failure.

A node that loses a path tells the peer with a `PathClose` on a
surviving one, so the peer moves at once rather than after its own
timeout. A transport that returns inside the five-minute grace revives
its dead paths as `Probing` with their RTT window and ETX intact.

Selection is measured, not configured: each path scores
`quality_index(etx, min_rtt)`, and traffic moves when the active path
is no longer eligible (mandatory) or when a standby beats it by
`switch_margin` for `switch_dwell_secs` (discretionary). Min RTT over a
window rather than SRTT, because SRTT inflates under load on the path
carrying traffic while an idle standby looks pristine — a ping-pong
generator. A `role: backup` transport carries a peer's traffic only
while no normal path is eligible, and yields outright when one becomes
eligible. `fipsctl path pin` overrides both while its path is eligible.

A switch re-seeds the path MTU from the new path, tightens the
session MTUs and refreshes the MSS ceiling, so the first frames after a
switch are not black-holed. The link record and `addr_to_link` follow
the active path. The link cost the tree sees is held at its pre-switch
value for the dwell, and until the two receiver reports that span the
switch have arrived — the first counts every frame in flight on the old
path as lost and spikes the per-report ETX for one interval, the second
replaces it — so neither a short flap nor that spike ripples mesh-wide
through parent selection or the next-hop order.

Operator surface: `role: backup` on any transport, `node.path.*`
(`switch_margin`, validated finite and at least 1.0, `switch_dwell_secs`,
`min_samples`, `active_heartbeat_ms`, `standby_heartbeat_ms`), and
`fipsctl path show|pin|unpin` over the `path_show`, `path_pin` and
`path_unpin` control commands.

Two chaos scenarios calibrate the defaults and are wired into both
runners: `dual-path-flap` (a raw-Ethernet veth as the cable, the Docker
bridge over UDP as the wifi) and `dual-udp-flap` (two interface-bound
UDP instances). Each flaps one path under iperf and carries detectors
that can fail on a switchover that did not carry traffic — a per-node
ceiling on "Peer promoted to active" (a second is a re-peering), a
one-second ceiling from link-down to the first switch, and a two-second
ceiling on any zero-byte iperf interval run — alongside the
`path_switches` band. Neither has been run to calibrate; the defaults
are chosen, not derived, and the design doc says so.

Refs #143
2026-09-23 11:48:33 -03:00

3302 lines
130 KiB
Rust

//! BLE L2CAP Transport Implementation
//!
//! Provides BLE-based transport for FIPS peer communication over L2CAP
//! Connection-Oriented Channels.
//!
//! ## Packet boundaries
//!
//! Message-boundary preservation is a property of the *socket type* a
//! backend uses, not of L2CAP. BlueZ's `SOCK_SEQPACKET` preserves SDU
//! boundaries; other backends expose an L2CAP channel as a byte stream and
//! may return a fragment of a packet or several packets coalesced from one
//! read. The receive path therefore recovers boundaries from the FMP length
//! prefix via [`stream_read::BleStreamRead`] and
//! `crate::transport::framing::read_fmp_packet`, which is a transparent
//! pass-through on a boundary-preserving backend.
//!
//! ## Architecture
//!
//! Transport logic (pool, neighbor, lifecycle) is separated from any one
//! Bluetooth stack via the `BleIo` trait. `BluerIo` drives BlueZ (behind
//! `cfg(bluer_available)`), [`io_android::AndroidIo`] drives a radio the
//! embedder supplies, and `MockBleIo` is an in-memory double for tests
//! without hardware. Which one `DefaultBleTransport` resolves to is decided
//! by the cascade below, and the whole module is compiled only on platforms
//! that have one of them — see `ble_available` in `build.rs`.
//!
//! ## Connection Pool
//!
//! BLE hardware limits concurrent connections (typically 4-10). The pool
//! enforces a configurable maximum (default 7) with priority eviction:
//! static (configured) peers get priority over discovered peers.
pub mod addr;
pub mod io;
/// A backend whose radio is supplied by the embedder rather than opened in
/// process.
///
/// Compiled under `cfg(test)` on every host as well as on the platform that
/// will select it, so its channel machinery, slot semantics and connect
/// routing are exercised by an ordinary test run on an ordinary runner. The
/// platform build of it is linted but executed nowhere, which is exactly why
/// the logic must not be behind a platform-only gate.
#[cfg(any(target_os = "android", test))]
pub mod io_android;
#[cfg(bluer_available)]
pub mod io_linux;
pub mod neighbor;
pub mod pool;
pub mod psm;
pub mod stats;
pub mod stream_read;
use super::framing::{StreamError, read_fmp_packet};
use super::{
ConnectionState, DiscoveredPeer, PacketTx, ReceivedPacket, Transport, TransportAddr,
TransportError, TransportId, TransportState, TransportType,
};
use crate::config::BleConfig;
use crate::identity::NodeAddr;
use addr::BleAddr;
use io::{BleIo, BleScanner, BleStream};
use neighbor::NeighborBuffer;
use pool::{BleConnection, ConnectionPool};
use stats::BleStats;
use stream_read::BleStreamRead;
use secp256k1::XOnlyPublicKey;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::AsyncReadExt;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tracing::{debug, info, trace, warn};
/// Default FIPS L2CAP PSM (Protocol Service Multiplexer).
///
/// 0x0085 (133) is in the dynamic range (0x0080-0x00FF).
///
/// This is a request and a fallback, not a guarantee. A backend whose
/// platform assigns the PSM reports back what it actually bound (see
/// [`io::BleIo::listen`]), and a peer that advertises its own PSM (see
/// [`psm`]) is dialled there instead. The configured value is what a peer is
/// dialled at when it advertises nothing.
pub const DEFAULT_PSM: u16 = 0x0085;
/// Concrete BLE transport type for use in `TransportHandle`.
///
/// Three arms, in priority order: an in-process BlueZ stack where one exists,
/// otherwise a radio the embedder supplies, otherwise — and *only* in a test
/// build — the in-memory double.
///
/// The mock arm is deliberately not written as "anything that is not BlueZ".
/// That phrasing is what makes widening the module gate dangerous: a platform
/// added to `ble_available` without a backend would silently land on an
/// in-memory transport that compiles, starts, reports [`TransportState::Up`]
/// and never peers, with nothing anywhere to say so. The tripwire below makes
/// that state unrepresentable instead.
#[cfg(all(bluer_available, not(test)))]
pub type DefaultBleTransport = BleTransport<io_linux::BluerIo>;
#[cfg(all(target_os = "android", not(bluer_available), not(test)))]
pub type DefaultBleTransport = BleTransport<io_android::AndroidIo>;
#[cfg(test)]
pub type DefaultBleTransport = BleTransport<io::MockBleIo>;
// The tripwire. This module is only compiled when `ble_available`, so
// reaching here means a platform declared it has BLE while having no concrete
// backend to provide it. It cannot fire today; it exists for whoever next
// widens `ble_available`, and it fails the build rather than shipping a
// transport that quietly never connects.
#[cfg(all(not(test), not(bluer_available), not(target_os = "android")))]
compile_error!(
"this target is `ble_available` but has no concrete `BleIo` backend. \
Add its backend and an arm to the `DefaultBleTransport` cascade in \
src/transport/ble/mod.rs, or drop the target from `ble_available` in \
build.rs. Falling back to the in-memory mock in a non-test build would \
produce a BLE transport that starts, reports itself up, and never peers."
);
// ============================================================================
// BLE Transport
// ============================================================================
/// BLE transport for FIPS.
///
/// Provides connection-oriented, reliable delivery over BLE L2CAP CoC.
/// Each peer has its own L2CAP connection; the pool enforces hardware
/// connection limits with priority eviction.
pub struct BleTransport<I: BleIo> {
/// Unique transport identifier.
transport_id: TransportId,
/// Optional instance name.
name: Option<String>,
/// Configuration.
config: BleConfig,
/// Current state.
state: TransportState,
/// BLE I/O implementation (BluerIo or MockBleIo).
io: Arc<I>,
/// Established connection pool.
pool: Arc<Mutex<ConnectionPool<Arc<I::Stream>>>>,
/// Pending connection attempts.
connecting: Arc<Mutex<HashMap<TransportAddr, ConnectingEntry>>>,
/// Channel for delivering received packets to Node.
packet_tx: PacketTx,
/// Accept loop task handle.
accept_task: Option<JoinHandle<()>>,
/// Combined scan + probe loop task handle.
scan_probe_task: Option<JoinHandle<()>>,
/// Neighbor buffer for discovered peers.
neighbor_buffer: Arc<NeighborBuffer>,
/// Transport statistics.
stats: Arc<BleStats>,
/// Our public key for pre-handshake identity exchange.
///
/// BLE advertisements carry only the FIPS UUID, not the pubkey.
/// After L2CAP connection, both sides exchange `[0x00][pubkey:32]`
/// so the node layer can initiate the IK handshake.
/// Temporary — removed when FMP switches to XX.
local_pubkey: Option<[u8; 32]>,
/// L2CAP listener PSMs read out of peers' advertisements, by link
/// address. A peer whose platform assigns its listener PSM cannot be
/// dialled at a configured constant, so it publishes the number it
/// actually bound and every dial to it — the scan loop's probe and the
/// node's [`connect_async`](Self::connect_async) alike — goes there. A
/// peer that advertises nothing is dialled at the configured PSM.
learned_psm: LearnedPsm,
}
/// Advertised listener PSM by link address, shared between the scan loop
/// that learns it and the dials that need it. A plain mutex: never held
/// across an await.
type LearnedPsm = Arc<std::sync::Mutex<HashMap<BleAddr, u16>>>;
/// The PSM to dial `addr` at: what it advertised, else `configured`.
fn dial_psm_for(learned: &LearnedPsm, addr: &BleAddr, configured: u16) -> u16 {
learned
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(addr)
.copied()
.unwrap_or(configured)
}
/// A learned PSM that did not answer is stale — forget it, so the next
/// advert re-learns it and the fallback applies in the meantime.
fn forget_psm(learned: &LearnedPsm, addr: &BleAddr) {
learned
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(addr);
}
/// A pending background connection attempt.
struct ConnectingEntry {
task: JoinHandle<()>,
}
impl<I: BleIo> BleTransport<I> {
/// Create a new BLE transport.
pub fn new(
transport_id: TransportId,
name: Option<String>,
config: BleConfig,
io: I,
packet_tx: PacketTx,
) -> Self {
let max_conns = config.max_connections();
Self {
transport_id,
name,
config,
state: TransportState::Configured,
io: Arc::new(io),
pool: Arc::new(Mutex::new(ConnectionPool::new(max_conns))),
connecting: Arc::new(Mutex::new(HashMap::new())),
packet_tx,
accept_task: None,
scan_probe_task: None,
neighbor_buffer: Arc::new(NeighborBuffer::new(transport_id)),
stats: Arc::new(BleStats::new()),
local_pubkey: None,
learned_psm: Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
/// Get the instance name.
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
/// Get the transport statistics.
pub fn stats(&self) -> &Arc<BleStats> {
&self.stats
}
/// Get the I/O implementation (for test injection).
pub fn io(&self) -> &Arc<I> {
&self.io
}
/// Set the local public key for pre-handshake identity exchange.
///
/// Must be called before `start_async()`. Without this, BLE
/// connections skip the pubkey exchange and discovered peers
/// won't have identity information for auto-connect.
pub fn set_local_pubkey(&mut self, pubkey: [u8; 32]) {
self.local_pubkey = Some(pubkey);
}
/// Start the transport asynchronously.
pub async fn start_async(&mut self) -> Result<(), TransportError> {
if !self.state.can_start() {
return Err(TransportError::AlreadyStarted);
}
self.state = TransportState::Starting;
let configured_psm = self.config.psm();
let adapter = self.io.adapter_name().to_string();
// The PSM peers should dial us on. Only the listener knows it: a
// backend whose platform assigns PSMs reports back something other
// than what was requested, and that is what has to be advertised.
let mut listener_psm = configured_psm;
// Start L2CAP listener for inbound connections
if self.config.accept_connections() {
match self.io.listen(configured_psm).await {
Ok((acceptor, bound_psm)) => {
listener_psm = bound_psm;
let pool = Arc::clone(&self.pool);
let packet_tx = self.packet_tx.clone();
let transport_id = self.transport_id;
let stats = Arc::clone(&self.stats);
let max_conns = self.config.max_connections();
self.accept_task = Some(tokio::spawn(accept_loop(
acceptor,
pool,
packet_tx,
transport_id,
stats,
max_conns,
self.local_pubkey,
Arc::clone(&self.neighbor_buffer),
)));
debug!(
adapter = %adapter,
psm = listener_psm,
requested_psm = configured_psm,
"BLE accept loop started"
);
}
Err(e) => {
warn!(adapter = %adapter, error = %e, "failed to start BLE listener");
self.state = TransportState::Failed;
return Err(e);
}
}
}
// Start continuous advertising
if self.config.advertise() {
if let Err(e) = self.io.start_advertising(listener_psm).await {
warn!(adapter = %adapter, error = %e, "failed to start BLE advertising");
} else {
self.stats.record_advertisement();
debug!(
adapter = %adapter,
psm = listener_psm,
"BLE advertising started (continuous)"
);
}
}
// Start combined scan + probe loop
if self.config.scan() {
match self.io.start_scanning().await {
Ok(scanner) => {
self.scan_probe_task = Some(tokio::spawn(scan_probe_loop::<I>(
scanner,
Arc::clone(&self.io),
Arc::clone(&self.pool),
Arc::clone(&self.neighbor_buffer),
Arc::clone(&self.stats),
self.local_pubkey,
self.config.psm(),
self.config.connect_timeout_ms(),
self.config.probe_cooldown_secs(),
Arc::clone(&self.learned_psm),
self.packet_tx.clone(),
self.transport_id,
)));
debug!(adapter = %adapter, "BLE scan+probe loop started");
}
Err(e) => {
warn!(adapter = %adapter, error = %e, "failed to start BLE scanning");
}
}
}
self.state = TransportState::Up;
info!(adapter = %adapter, psm = listener_psm, "BLE transport started");
Ok(())
}
/// Stop the transport asynchronously.
pub async fn stop_async(&mut self) -> Result<(), TransportError> {
// Stop advertising
let _ = self.io.stop_advertising().await;
// Stop scanning. Aborting the scan task below stops us reading
// adverts; on a backend whose radio the embedder owns, only this
// stops the radio.
let _ = self.io.stop_scanning().await;
// Abort accept loop
if let Some(task) = self.accept_task.take() {
task.abort();
}
// Abort scan+probe loop
if let Some(task) = self.scan_probe_task.take() {
task.abort();
}
// Drain connecting pool
{
let mut connecting = self.connecting.lock().await;
for (_, entry) in connecting.drain() {
entry.task.abort();
}
}
// Drain established connections (recv tasks aborted via Drop)
{
let mut pool = self.pool.lock().await;
for addr in pool.addrs() {
pool.remove(&addr);
}
}
self.state = TransportState::Down;
info!("BLE transport stopped");
Ok(())
}
/// Send data to a remote BLE address.
///
/// If no connection exists, triggers a background connect and fails
/// fast. The next send retry (typically 1s later for handshake msg1)
/// will find the connection established. This avoids blocking the
/// event loop on L2CAP connect (up to 10s).
pub async fn send_async(
&self,
addr: &TransportAddr,
data: &[u8],
) -> Result<usize, TransportError> {
// Take the MTU and the connection's send queue, then release the pool
// lock. Everything after this point must be lock-free: the previous
// shape awaited the L2CAP write while still holding this guard, so a
// peer that stopped draining blocked not just its own sender but every
// other BLE operation — connect, eviction, the receive loops' teardown.
let found = {
let pool = self.pool.lock().await;
pool.get(addr)
.map(|c| (c.effective_mtu() as usize, c.send_tx.clone()))
};
let (mtu, send_tx) = match found {
Some(pair) => pair,
None => {
// Fire-and-forget: connect_async spawns a background task
let _ = self.connect_async(addr).await;
return Err(TransportError::SendFailed("not connected".into()));
}
};
// MTU check
if data.len() > mtu {
self.stats.record_mtu_exceeded();
return Err(TransportError::MtuExceeded {
packet_size: data.len(),
mtu: mtu as u16,
});
}
// Queue the frame for the connection's writer task. `try_send`, not
// `send`: waiting for a slot is the same stall in a different shape,
// which is what the Android backend's own queue does one layer down.
// The byte count is what was queued; bytes on the link are recorded by
// the writer task.
match send_tx.try_send(data.to_vec()) {
Ok(()) => Ok(data.len()),
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
self.stats.record_send_error();
debug!(
addr = %addr,
depth = pool::SEND_QUEUE_DEPTH,
"BLE outbound queue full; peer is not draining"
);
Err(TransportError::SendFailed(
"outbound queue full: peer not draining".into(),
))
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
self.stats.record_send_error();
Err(TransportError::SendFailed("connection writer gone".into()))
}
}
}
/// Connect to a remote BLE device inline (blocking the caller).
///
/// Not used in normal operation (send_async fails fast instead).
/// Retained for manual debugging / testing scenarios.
#[allow(dead_code)]
async fn connect_inline(&self, addr: &TransportAddr) -> Result<(), TransportError> {
let ble_addr = BleAddr::parse(
addr.as_str()
.ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?,
)?;
let psm = self.config.psm();
let timeout_ms = self.config.connect_timeout_ms();
let stream = match tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
self.io.connect(&ble_addr, psm),
)
.await
{
Ok(Ok(stream)) => stream,
Ok(Err(e)) => {
self.stats.record_connect_error();
debug!(
addr = %addr, role = "central", outcome = "connect-error", error = %e,
"BLE connect-on-send failed"
);
return Err(TransportError::ConnectionRefused);
}
Err(_) => {
self.stats.record_connect_timeout();
debug!(
addr = %addr, role = "central", outcome = "connect-timeout",
"BLE connect-on-send timeout"
);
return Err(TransportError::Timeout);
}
};
// One reader for the life of the connection: the pubkey exchange and
// the receive loop must share it, or bytes the peer coalesced behind
// the exchange are dropped at the hand-off.
let stream = Arc::new(stream);
let recv_mtu = stream.recv_mtu();
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
// Pre-handshake pubkey exchange (temporary, pre-XX)
let mut peer_node: Option<NodeAddr> = None;
if let Some(ref our_pubkey) = self.local_pubkey {
match pubkey_exchange(stream.as_ref(), &mut reader, our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %addr, "BLE outbound pubkey exchange complete");
let node = NodeAddr::from_pubkey(&peer_pubkey);
peer_node = Some(node);
let announced = announced_addr(&self.pool, &node, &ble_addr).await;
self.neighbor_buffer
.add_peer_with_pubkey(&announced, peer_pubkey);
}
Err(e) => {
self.stats.record_pubkey_exchange_failure();
warn!(
addr = %addr, role = "central", outcome = "pubkey-exchange-failed",
error = %e, "BLE outbound pubkey exchange failed"
);
return Err(e);
}
}
}
self.promote_connection(addr, &ble_addr, stream, reader, peer_node)
.await
}
/// Promote a newly established stream into the connection pool.
///
/// Spawns the receive loop and inserts into the pool with eviction.
async fn promote_connection(
&self,
addr: &TransportAddr,
ble_addr: &BleAddr,
stream: Arc<I::Stream>,
reader: BleStreamRead<I::Stream>,
node_addr: Option<NodeAddr>,
) -> Result<(), TransportError> {
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let recv_task = tokio::spawn(receive_loop(
reader,
addr.clone(),
Arc::clone(&self.pool),
self.packet_tx.clone(),
self.transport_id,
Arc::clone(&self.stats),
recv_mtu,
));
let (send_tx, send_rx) = tokio::sync::mpsc::channel(pool::SEND_QUEUE_DEPTH);
let send_task = tokio::spawn(send_loop(
Arc::clone(&stream),
send_rx,
addr.clone(),
Arc::clone(&self.pool),
Arc::clone(&self.stats),
));
let conn = BleConnection {
stream,
send_tx,
send_task: Some(send_task),
recv_task: Some(recv_task),
send_mtu,
recv_mtu,
established_at: tokio::time::Instant::now(),
is_static: false,
addr: ble_addr.clone(),
node_addr,
};
let mut pool = self.pool.lock().await;
match pool.insert(addr.clone(), conn) {
Ok(Some(evicted)) => {
self.stats.record_pool_eviction();
debug!(addr = %addr, evicted = %evicted, "BLE connection established (evicted peer)");
}
Ok(None) => {
debug!(addr = %addr, "BLE connection established");
}
Err(e) => {
warn!(addr = %addr, error = %e, "BLE pool full, connection dropped");
self.stats.record_connection_rejected();
return Err(TransportError::SendFailed("pool full".into()));
}
}
self.stats.record_connection_established();
Ok(())
}
/// Initiate a non-blocking connection to a remote BLE device.
///
/// Spawns a background task that connects with timeout and promotes
/// to the pool on success. Poll `connection_state_sync()` to check.
pub async fn connect_async(&self, addr: &TransportAddr) -> Result<(), TransportError> {
// Already connected?
{
let pool = self.pool.lock().await;
if pool.contains(addr) {
return Ok(());
}
}
// Already connecting?
{
let connecting = self.connecting.lock().await;
if connecting.contains_key(addr) {
return Ok(());
}
}
let ble_addr = BleAddr::parse(
addr.as_str()
.ok_or_else(|| TransportError::InvalidAddress("not valid UTF-8".into()))?,
)?;
let io = Arc::clone(&self.io);
let pool = Arc::clone(&self.pool);
let connecting = Arc::clone(&self.connecting);
let packet_tx = self.packet_tx.clone();
let transport_id = self.transport_id;
let stats = Arc::clone(&self.stats);
// At whatever PSM this peer advertised. The configured value is only
// right for a peer that advertises nothing; on a platform that
// assigns listener PSMs it is never the one the peer is listening on.
let learned_psm = Arc::clone(&self.learned_psm);
let psm = dial_psm_for(&learned_psm, &ble_addr, self.config.psm());
let timeout_ms = self.config.connect_timeout_ms();
let addr_clone = addr.clone();
let local_pubkey = self.local_pubkey;
let neighbor_buffer = Arc::clone(&self.neighbor_buffer);
let task = tokio::spawn(async move {
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
io.connect(&ble_addr, psm),
)
.await;
if !matches!(result, Ok(Ok(_))) {
forget_psm(&learned_psm, &ble_addr);
}
// Remove from connecting pool
connecting.lock().await.remove(&addr_clone);
match result {
Ok(Ok(stream)) => {
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
// One reader across both phases — see `pubkey_exchange`.
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
// Pre-handshake pubkey exchange (temporary, pre-XX)
let mut peer_node: Option<NodeAddr> = None;
if let Some(ref our_pubkey) = local_pubkey {
match pubkey_exchange(stream.as_ref(), &mut reader, our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %addr_clone, "BLE outbound pubkey exchange complete");
let node = NodeAddr::from_pubkey(&peer_pubkey);
peer_node = Some(node);
let announced = announced_addr(&pool, &node, &ble_addr).await;
neighbor_buffer.add_peer_with_pubkey(&announced, peer_pubkey);
}
Err(e) => {
stats.record_pubkey_exchange_failure();
warn!(
addr = %addr_clone,
role = "central",
outcome = "pubkey-exchange-failed",
error = %e,
"BLE outbound pubkey exchange failed"
);
return;
}
}
}
let recv_task = tokio::spawn(receive_loop(
reader,
addr_clone.clone(),
Arc::clone(&pool),
packet_tx,
transport_id,
Arc::clone(&stats),
recv_mtu,
));
let (send_tx, send_rx) = tokio::sync::mpsc::channel(pool::SEND_QUEUE_DEPTH);
let send_task = tokio::spawn(send_loop(
Arc::clone(&stream),
send_rx,
addr_clone.clone(),
Arc::clone(&pool),
Arc::clone(&stats),
));
let conn = BleConnection {
stream,
send_tx,
send_task: Some(send_task),
recv_task: Some(recv_task),
send_mtu,
recv_mtu,
established_at: tokio::time::Instant::now(),
is_static: false,
addr: ble_addr,
node_addr: peer_node,
};
let mut pool = pool.lock().await;
match pool.insert(addr_clone.clone(), conn) {
Ok(Some(evicted)) => {
stats.record_pool_eviction();
debug!(addr = %addr_clone, evicted = %evicted, "BLE connection established (evicted peer)");
}
Ok(None) => {
debug!(addr = %addr_clone, "BLE connection established");
}
Err(e) => {
warn!(addr = %addr_clone, error = %e, "BLE pool full, connection dropped");
stats.record_connection_rejected();
return;
}
}
stats.record_connection_established();
}
Ok(Err(e)) => {
stats.record_connect_error();
debug!(
addr = %addr_clone, role = "central", outcome = "connect-error",
psm, error = %e, "BLE connect failed"
);
}
Err(_) => {
stats.record_connect_timeout();
debug!(
addr = %addr_clone, role = "central", outcome = "connect-timeout",
psm, "BLE connect timeout"
);
}
}
});
self.connecting
.lock()
.await
.insert(addr.clone(), ConnectingEntry { task });
Ok(())
}
/// Query the state of a connection attempt.
pub fn connection_state_sync(&self, addr: &TransportAddr) -> ConnectionState {
// Check established pool (try_lock to avoid blocking)
if let Ok(pool) = self.pool.try_lock()
&& pool.contains(addr)
{
return ConnectionState::Connected;
}
// Check connecting pool
if let Ok(connecting) = self.connecting.try_lock()
&& connecting.contains_key(addr)
{
return ConnectionState::Connecting;
}
ConnectionState::None
}
/// Close a specific connection.
pub async fn close_connection_async(&self, addr: &TransportAddr) {
let mut pool = self.pool.lock().await;
if let Some(conn) = pool.remove(addr) {
debug!(addr = %addr, "BLE connection closed");
drop(conn); // recv_task aborted via Drop
}
}
/// Get the link MTU for a specific address.
pub fn link_mtu(&self, addr: &TransportAddr) -> u16 {
if let Ok(pool) = self.pool.try_lock()
&& let Some(conn) = pool.get(addr)
{
return conn.effective_mtu();
}
self.config.mtu()
}
}
impl<I: BleIo> Transport for BleTransport<I> {
fn role(&self) -> crate::config::TransportRole {
self.config.role()
}
fn transport_id(&self) -> TransportId {
self.transport_id
}
fn transport_type(&self) -> &TransportType {
&TransportType::BLE
}
fn state(&self) -> TransportState {
self.state
}
fn mtu(&self) -> u16 {
self.config.mtu()
}
fn link_mtu(&self, addr: &TransportAddr) -> u16 {
self.link_mtu(addr)
}
fn start(&mut self) -> Result<(), TransportError> {
Err(TransportError::NotSupported(
"use start_async() for BLE transport".into(),
))
}
fn stop(&mut self) -> Result<(), TransportError> {
Err(TransportError::NotSupported(
"use stop_async() for BLE transport".into(),
))
}
fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> {
Err(TransportError::NotSupported(
"use send_async() for BLE transport".into(),
))
}
fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
Ok(self.neighbor_buffer.take())
}
fn auto_connect(&self) -> bool {
self.config.auto_connect()
}
fn accept_connections(&self) -> bool {
self.config.accept_connections()
}
fn close_connection(&self, _addr: &TransportAddr) {
// use close_connection_async()
}
}
// ============================================================================
// Background Tasks
// ============================================================================
/// Pre-handshake pubkey exchange prefix byte.
///
/// Distinguishes the identity exchange from FMP packets (version ≥ 0x01).
/// Temporary — removed when FMP switches from IK to XX handshake.
///
/// Caution: this prefix is *not* distinguishable from an FMP packet by the
/// framer. `0x00` decodes as FMP version 0, phase 0 (established), with a
/// payload length read out of the pubkey's own bytes — i.e. arbitrary. Any
/// code that runs the framer over a connection before the exchange has been
/// fully consumed will mis-frame badly. Threading one reader through both
/// phases is what guarantees the ordering.
const PUBKEY_EXCHANGE_PREFIX: u8 = 0x00;
/// Pre-handshake pubkey exchange message size: `[0x00][pubkey:32]`.
const PUBKEY_EXCHANGE_SIZE: usize = 33;
/// Timeout for pubkey exchange recv (seconds).
///
/// The peer should respond in milliseconds; 5s is generous. Without this,
/// a peer that connects but never sends its pubkey blocks the calling task
/// forever — killing scan_probe_loop, accept_loop, or the event loop.
const PUBKEY_EXCHANGE_TIMEOUT_SECS: u64 = 5;
/// The link address a completed pubkey exchange should be announced under.
///
/// A peer using resolvable private addresses presents a different link
/// address on every rotation, so the address an exchange happened on is a
/// transient alias for the peer, not a durable way to name it. Announcing the
/// alias makes a consumer that compares addresses treat it as a *new path* to
/// a peer it is already connected to and dial it; the duplicate is declined
/// here, so it never reaches the pool, so nothing upstream remembers the
/// conclusion and the next discovery round pays the same connect and exchange
/// again. `scan_probe_loop` breaks that cycle for its own probes, but callers
/// that reach `connect_async` directly never consult it.
///
/// So when the peer is already connected, report the address its link is
/// actually on: same peer, named by the address that works. When it is not,
/// there is no incumbent and the observed address stands.
///
/// Canonicalising rather than withholding matters: suppressing the
/// announcement would also stop the peer being offered at all, and consumers
/// legitimately re-probe a peer whose link has gone idle to recover it.
async fn announced_addr<S>(
pool: &Mutex<ConnectionPool<S>>,
node: &NodeAddr,
observed: &BleAddr,
) -> BleAddr {
pool.lock()
.await
.live_addr_of_node(node)
.unwrap_or_else(|| observed.clone())
}
/// Exchange public keys over a newly established L2CAP connection.
///
/// Both sides send `[0x00][our_pubkey:32]` and receive the peer's.
/// Returns the peer's XOnlyPublicKey on success.
///
/// Reads through the connection's `BleStreamRead` rather than calling
/// `recv` directly, for two reasons. It reassembles an exchange a
/// stream-oriented backend fragmented, which a single `recv` with an
/// exact-length check can never do. And anything the peer coalesced behind
/// the exchange stays buffered in the reader that the receive loop then
/// takes over, instead of being discarded at the hand-off.
async fn pubkey_exchange<S: BleStream + 'static>(
stream: &S,
reader: &mut BleStreamRead<S>,
local_pubkey: &[u8; 32],
) -> Result<XOnlyPublicKey, TransportError> {
let timeout = std::time::Duration::from_secs(PUBKEY_EXCHANGE_TIMEOUT_SECS);
// Send our pubkey. The deadline matters as much as the one below it: a
// peer that never drains the L2CAP channel stalls the write instead.
let mut msg = [0u8; PUBKEY_EXCHANGE_SIZE];
msg[0] = PUBKEY_EXCHANGE_PREFIX;
msg[1..].copy_from_slice(local_pubkey);
match tokio::time::timeout(timeout, stream.send(&msg)).await {
Ok(result) => result?,
Err(_) => return Err(TransportError::Timeout),
}
// Receive peer's pubkey (with timeout to prevent indefinite blocking)
let mut buf = [0u8; PUBKEY_EXCHANGE_SIZE];
let timeout = std::time::Duration::from_secs(PUBKEY_EXCHANGE_TIMEOUT_SECS);
match tokio::time::timeout(timeout, reader.read_exact(&mut buf)).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
return Err(TransportError::RecvFailed(format!(
"pubkey exchange: {}",
e
)));
}
Err(_) => return Err(TransportError::Timeout),
}
if buf[0] != PUBKEY_EXCHANGE_PREFIX {
return Err(TransportError::RecvFailed(format!(
"pubkey exchange: bad prefix 0x{:02X}",
buf[0]
)));
}
XOnlyPublicKey::from_slice(&buf[1..])
.map_err(|e| TransportError::RecvFailed(format!("pubkey exchange: invalid key: {}", e)))
}
// Beacon loop removed — advertising is now continuous (started once
// in start_async, stopped in stop_async). BLE advertising overhead
// is negligible (~0.15% duty cycle on advertising channels).
/// Inbound handshakes allowed to be in flight at once.
///
/// Deliberately independent of the pool capacity: that is the budget for
/// established links, and tying the two together would mean an operator who
/// sets `max_connections = 1` also gets a serial accept loop, which is the
/// defect this bound exists to close. A healthy exchange is one round trip
/// and completes in milliseconds, so this is never reached honestly. Raising
/// it lets a flood hold more concurrent handshakes; lowering it makes a
/// legitimate slow peer likelier to be aborted under flood.
const INBOUND_HANDSHAKE_INFLIGHT: usize = 8;
/// Accept loop: accepts inbound L2CAP connections and hands each to its own
/// task for the pubkey exchange and pool insert.
///
/// One iteration is bounded by `accept()` alone. Nothing a connecting peer
/// chooses to do can delay the next accept: the handshake runs off the loop,
/// and when the in-flight budget is full the oldest pending handshake is
/// aborted to make room rather than the loop waiting for one to finish.
///
/// The in-flight tasks live in a `JoinSet` and not behind a `Semaphore` for a
/// reason that is easy to lose: `stop_async` aborts this task and nothing
/// else, so dropping the `JoinSet` with it is what stops the handshakes. Bare
/// `tokio::spawn` would leave them running past stop, able to insert into a
/// pool that stop has just drained.
#[allow(clippy::too_many_arguments)]
async fn accept_loop<A>(
mut acceptor: A,
pool: Arc<Mutex<ConnectionPool<Arc<A::Stream>>>>,
packet_tx: PacketTx,
transport_id: TransportId,
stats: Arc<BleStats>,
_max_conns: usize,
local_pubkey: Option<[u8; 32]>,
neighbor_buffer: Arc<NeighborBuffer>,
) where
A: io::BleAcceptor,
A::Stream: 'static,
{
let mut inflight: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
// Spawn order, so the oldest handshake is the one evicted at the budget.
let mut pending: std::collections::VecDeque<tokio::task::AbortHandle> =
std::collections::VecDeque::new();
loop {
// Reap anything finished. Neither call waits. `retain` rather than
// popping the front run, so a handshake that completed out of order
// still frees its slot instead of being aborted as the oldest later.
while inflight.try_join_next().is_some() {}
pending.retain(|handle| !handle.is_finished());
match acceptor.accept().await {
Ok(stream) => {
let addr = stream.remote_addr().clone();
let ta = addr.to_transport_addr();
// Skip if already connected (outbound won the race). This
// awaits only our own mutex, never the peer.
{
let pool_guard = pool.lock().await;
if pool_guard.contains(&ta) {
debug!(addr = %ta, "BLE inbound: already connected, skipping");
continue;
}
}
if pending.len() >= INBOUND_HANDSHAKE_INFLIGHT
&& let Some(oldest) = pending.pop_front()
{
oldest.abort();
stats.record_handshake_aborted();
debug!(
addr = %ta,
budget = INBOUND_HANDSHAKE_INFLIGHT,
"BLE inbound handshake budget full, aborting the oldest"
);
}
let handle = inflight.spawn(admit_inbound(
stream,
Arc::clone(&pool),
packet_tx.clone(),
transport_id,
Arc::clone(&stats),
local_pubkey,
Arc::clone(&neighbor_buffer),
));
pending.push_back(handle);
}
Err(e) => {
warn!(error = %e, "BLE accept error");
break;
}
}
}
}
/// Run one inbound connection's pubkey exchange and admit it to the pool.
///
/// Runs off the accept loop so a peer that never answers delays nobody else.
/// Everything the accept loop used to do inline lives here, including the
/// duplicate-node decline and the cross-probe tie-break that `fix/platform-ble`
/// added: moving the work off the loop must not drop the checks that guard it.
/// The loop's `continue` becomes `return` — this task admits one connection.
#[allow(clippy::too_many_arguments)]
async fn admit_inbound<S>(
stream: S,
pool: Arc<Mutex<ConnectionPool<Arc<S>>>>,
packet_tx: PacketTx,
transport_id: TransportId,
stats: Arc<BleStats>,
local_pubkey: Option<[u8; 32]>,
neighbor_buffer: Arc<NeighborBuffer>,
) where
S: BleStream + 'static,
{
let addr = stream.remote_addr().clone();
let ta = addr.to_transport_addr();
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
// One reader across both phases — see `pubkey_exchange`.
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
// Pre-handshake pubkey exchange (temporary, pre-XX)
let mut peer_node_addr: Option<NodeAddr> = None;
if let Some(ref our_pubkey) = local_pubkey {
match pubkey_exchange(stream.as_ref(), &mut reader, our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %ta, "BLE inbound pubkey exchange complete");
let peer_node = NodeAddr::from_pubkey(&peer_pubkey);
peer_node_addr = Some(peer_node);
let announced = announced_addr(&pool, &peer_node, &addr).await;
neighbor_buffer.add_peer_with_pubkey(&announced, peer_pubkey);
// Already linked to this peer on another address?
// A peer using resolvable private addresses rotates
// continually, and every rotation dials in looking
// like a new device. Admitting those would put one
// peer in several pool slots and evict real ones.
// The incumbent link is kept: it is known-good, and
// a genuinely dead one is already reaped by the
// send-error and receive-loop paths.
//
// This is also the whole of link arbitration: the
// first link to a peer that enters the pool is the one
// kept, on both sides, whichever side dialled it. There
// used to be an ordering rule on top — the smaller node
// address stood its inbound down so that its own
// outbound would win — and it deadlocked whenever that
// outbound could not succeed: a peer whose dial was
// refused every time (wrong PSM, or the controller
// still holding the ACL of the inbound just dropped)
// stood down a working link every thirty seconds in
// favour of one it could never build. Ordering cannot
// be honoured once a link is admitted without the two
// sides disagreeing about which link is live, so it
// only ever decided the case that needed no deciding.
let dup = {
let pool_guard = pool.lock().await;
pool_guard.find_by_node(&peer_node)
};
if let Some(existing) = dup
&& existing != ta
{
debug!(
addr = %ta,
role = "peripheral",
outcome = "duplicate-node-decline",
existing = %existing,
"BLE inbound: peer already connected on another address, dropping duplicate"
);
stats.record_duplicate_node_decline();
return;
}
}
Err(e) => {
stats.record_pubkey_exchange_failure();
debug!(
addr = %ta, role = "peripheral",
outcome = "pubkey-exchange-failed", error = %e,
"BLE inbound pubkey exchange failed"
);
return;
}
}
}
// Spawn receive loop
let recv_task = tokio::spawn(receive_loop(
reader,
ta.clone(),
Arc::clone(&pool),
packet_tx.clone(),
transport_id,
Arc::clone(&stats),
recv_mtu,
));
let (send_tx, send_rx) = tokio::sync::mpsc::channel(pool::SEND_QUEUE_DEPTH);
let send_task = tokio::spawn(send_loop(
Arc::clone(&stream),
send_rx,
ta.clone(),
Arc::clone(&pool),
Arc::clone(&stats),
));
let conn = BleConnection {
stream,
send_tx,
send_task: Some(send_task),
recv_task: Some(recv_task),
send_mtu,
recv_mtu,
established_at: tokio::time::Instant::now(),
is_static: false,
addr,
node_addr: peer_node_addr,
};
let mut pool_guard = pool.lock().await;
match pool_guard.insert(ta.clone(), conn) {
Ok(Some(evicted)) => {
stats.record_pool_eviction();
info!(addr = %ta, evicted = %evicted, "BLE inbound accepted (evicted peer)");
}
Ok(None) => {
info!(addr = %ta, send_mtu, recv_mtu, "BLE inbound connection accepted");
}
Err(e) => {
stats.record_connection_rejected();
warn!(
addr = %ta, role = "peripheral", outcome = "pool-rejected",
error = %e, "BLE pool full, inbound connection rejected"
);
return;
}
}
stats.record_connection_accepted();
}
/// Per-connection writer task: the only place a BLE write is ever awaited.
///
/// The BLE case was the worst of the connection-oriented transports. The write
/// was awaited by the caller *while holding the pool mutex*, so a peer that
/// stopped draining its L2CAP link blocked every other BLE operation as well
/// as the caller's task — connects, evictions and each receive loop's
/// teardown all queue behind that one guard. Moving the write here removes
/// both halves of that: the caller enqueues and returns, and the pool lock is
/// never held across the link.
///
/// On a write error the connection is removed from the pool, which is where
/// the old inline path put it too. Dropping the pool entry aborts this task
/// and the receive task through `BleConnection`'s `Drop`.
async fn send_loop<S: BleStream + 'static>(
stream: Arc<S>,
mut frames: tokio::sync::mpsc::Receiver<Vec<u8>>,
addr: TransportAddr,
pool: Arc<Mutex<ConnectionPool<Arc<S>>>>,
stats: Arc<BleStats>,
) {
while let Some(frame) = frames.recv().await {
match stream.send(&frame).await {
Ok(()) => stats.record_send(frame.len()),
Err(e) => {
stats.record_send_error();
warn!(addr = %addr, error = %e, "BLE send failed, connection removed");
pool.lock().await.remove(&addr);
return;
}
}
}
}
/// Receive loop: reads packets from a BLE stream and delivers to node.
///
/// Takes the connection's `BleStreamRead` — already positioned past the
/// pubkey exchange, and still holding anything the peer coalesced behind it
/// — and pulls whole FIPS packets out of it using the FMP length prefix.
/// Boundaries come from the bytes, not from the backend's socket type, so a
/// fragment is reassembled and a coalesced tail is not lost.
async fn receive_loop<S: BleStream + 'static>(
mut reader: BleStreamRead<S>,
addr: TransportAddr,
pool: Arc<Mutex<ConnectionPool<Arc<S>>>>,
packet_tx: PacketTx,
transport_id: TransportId,
stats: Arc<BleStats>,
recv_mtu: u16,
) {
loop {
match read_fmp_packet(&mut reader, recv_mtu).await {
Ok(data) => {
stats.record_recv(data.len());
let packet = ReceivedPacket::new(transport_id, addr.clone(), data);
if packet_tx.send(packet).await.is_err() {
trace!("BLE packet_tx closed, stopping receive loop");
break;
}
}
Err(StreamError::Io(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!(addr = %addr, "BLE connection closed by peer");
break;
}
Err(e) => {
debug!(addr = %addr, error = %e, "BLE receive error");
stats.record_recv_error();
break;
}
}
}
// Remove from pool
let mut pool = pool.lock().await;
pool.remove(&addr);
}
/// Consecutive-failure backoff ceiling for a pending address, as a power of
/// two multiple of the base cooldown. At the 30 s default this caps a failing
/// address at one dial attempt every 16 minutes.
const MAX_PROBE_BACKOFF_SHIFT: u32 = 5;
/// Ceiling on how many discovered-but-unconnected addresses are kept for
/// retry. Resolvable private addresses rotate, so without a bound the book
/// grows for the life of the process; with one, the total retry dial rate is
/// bounded too (at most one dial per retry tick, spread over the book).
const MAX_PENDING_PROBES: usize = 32;
/// One discovered address awaiting a successful probe.
#[derive(Debug, Clone)]
struct PendingProbe {
addr: BleAddr,
/// Consecutive failed probes. Reset only by removal from the book, which
/// every conclusive outcome (connected, duplicate declined, already
/// pooled) performs.
failures: u32,
/// Earliest instant at which this address may be dialled again.
next_attempt: tokio::time::Instant,
}
/// The retry book for addresses the scanner has offered but which are not yet
/// connected.
///
/// Exists because a scanner is not a reliable repeat source: BlueZ emits
/// `DeviceAdded` once per address per discovery session, so an address the
/// probe loop forgets is never offered again. Everything here therefore
/// throttles rather than discards — an entry leaves the book on a *conclusive*
/// outcome, or when [`MAX_PENDING_PROBES`] other addresses compete for its
/// slot, never because it failed.
///
/// Two properties matter:
///
/// - **Consecutive failures back an address off exponentially.** A dead
/// address is retried on a doubling interval up to
/// [`MAX_PROBE_BACKOFF_SHIFT`], instead of being re-dialled every cooldown
/// forever. Addresses rotate and links are lossy, so a handful of failures
/// is normal and must not retire a peer that is still there.
/// - **The retry tick rotates.** Probing only the head of the book let one
/// slow or dead address starve every other pending address behind it, which
/// on a busy radio is most of them.
#[derive(Debug)]
struct PendingProbes {
entries: Vec<PendingProbe>,
cooldown: std::time::Duration,
}
impl PendingProbes {
fn new(cooldown: std::time::Duration) -> Self {
Self {
entries: Vec::new(),
cooldown,
}
}
fn position(&self, addr: &BleAddr) -> Option<usize> {
self.entries.iter().position(|e| &e.addr == addr)
}
/// Record a sighting. A previously unseen address becomes immediately
/// eligible; a known one keeps whatever backoff it has earned, so a
/// scanner that re-reports the same address many times a second cannot
/// wash out the backoff.
///
/// When the book is full the *most-failed* entry is evicted to make room,
/// which is the entry least likely to still have a peer behind it.
fn observe(&mut self, addr: &BleAddr, now: tokio::time::Instant) {
if self.position(addr).is_some() {
return;
}
if self.entries.len() >= MAX_PENDING_PROBES
&& let Some(worst) = self
.entries
.iter()
.enumerate()
.max_by_key(|(_, e)| (e.failures, e.next_attempt))
.map(|(i, _)| i)
{
self.entries.remove(worst);
}
self.entries.push(PendingProbe {
addr: addr.clone(),
failures: 0,
next_attempt: now,
});
}
/// Whether `addr` may be dialled now. An address that is not in the book
/// has no history to hold it back.
fn is_due(&self, addr: &BleAddr, now: tokio::time::Instant) -> bool {
match self.position(addr) {
Some(i) => self.entries[i].next_attempt <= now,
None => true,
}
}
/// Note that a probe is starting: hold the address for one base cooldown
/// so the attempt in flight is not duplicated.
fn mark_attempt(&mut self, addr: &BleAddr, now: tokio::time::Instant) {
if let Some(i) = self.position(addr) {
self.entries[i].next_attempt = now + self.cooldown;
}
}
/// Note that a probe failed. Returns the new consecutive-failure count.
fn record_failure(&mut self, addr: &BleAddr, now: tokio::time::Instant) -> u32 {
let Some(i) = self.position(addr) else {
return 0;
};
let e = &mut self.entries[i];
e.failures = e.failures.saturating_add(1);
let shift = (e.failures - 1).min(MAX_PROBE_BACKOFF_SHIFT);
e.next_attempt = now + self.cooldown * 2u32.pow(shift);
e.failures
}
/// Drop an address that reached a conclusive outcome.
fn resolve(&mut self, addr: &BleAddr) {
self.entries.retain(|e| &e.addr != addr);
}
/// Drop every address for which `connected` reports a live pool entry.
fn drop_connected(&mut self, connected: impl Fn(&BleAddr) -> bool) {
self.entries.retain(|e| !connected(&e.addr));
}
/// The next address due for a retry, rotated to the back of the book so
/// the following tick starts after it rather than on it.
fn next_due(&mut self, now: tokio::time::Instant) -> Option<BleAddr> {
let i = self.entries.iter().position(|e| e.next_attempt <= now)?;
let entry = self.entries.remove(i);
let addr = entry.addr.clone();
self.entries.push(entry);
Some(addr)
}
}
/// Combined scan + probe loop.
///
/// Scanner events arrive continuously (both sides advertise continuously).
/// Each scan result is probed immediately unless the address is in cooldown
/// (recently probed) or already connected. On successful probe, the
/// connection is promoted directly into the pool (no second L2CAP connect
/// needed) and the peer is reported to the neighbor buffer for the node
/// layer to auto-connect.
///
/// Cooldown prevents rapid re-probing of the same address: after any probe
/// attempt (success or failure), the address is suppressed for
/// `cooldown_secs`. Connected peers are filtered by pool membership.
#[allow(clippy::too_many_arguments)]
async fn scan_probe_loop<I: io::BleIo>(
mut scanner: I::Scanner,
io: Arc<I>,
pool: Arc<Mutex<ConnectionPool<Arc<I::Stream>>>>,
buffer: Arc<NeighborBuffer>,
stats: Arc<BleStats>,
local_pubkey: Option<[u8; 32]>,
configured_psm: u16,
connect_timeout_ms: u64,
cooldown_secs: u64,
learned_psm: LearnedPsm,
packet_tx: PacketTx,
transport_id: TransportId,
) {
// Addresses discovered but not yet connected — retried after cooldown even
// if the scanner doesn't fire again (BlueZ deduplicates), on a per-address
// backoff that widens with consecutive failures. Also the cooldown record:
// an address leaves the book the moment it reaches a conclusive outcome,
// after which the pool and `known_node_of` guards below cover it.
let mut pending = PendingProbes::new(std::time::Duration::from_secs(cooldown_secs));
// Link addresses already resolved to a node identity by a completed pubkey
// exchange. Lets the loop skip an address it has *already* learned belongs
// to a peer it is connected to, instead of paying a full connect and
// exchange to rediscover that every cooldown. Rotation means this grows by
// one per rotation, so entries are dropped once their node is no longer in
// the pool — a peer that genuinely goes away is probed again normally.
let mut known_node_of: HashMap<BleAddr, NodeAddr> = HashMap::new();
let retry_interval = tokio::time::interval(std::time::Duration::from_secs(cooldown_secs));
tokio::pin!(retry_interval);
retry_interval.tick().await; // consume initial tick
loop {
// Either a scanner event or the retry timer fires
let addr = tokio::select! {
result = scanner.next() => {
match result {
Some(advert) => {
if let Some(psm) = advert.psm {
trace!(addr = %advert.addr, psm, "BLE scan: learned peer PSM");
learned_psm
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(advert.addr.clone(), psm);
}
advert.addr
}
None => {
debug!("BLE scanner ended");
break;
}
}
}
_ = retry_interval.tick() => {
// Re-probe pending addresses that aren't connected
let pool_guard = pool.lock().await;
pending.drop_connected(|a| pool_guard.contains(&a.to_transport_addr()));
drop(pool_guard);
// Rotating rather than always taking the head is what stops one
// slow or dead address from starving every other pending one.
match pending.next_due(tokio::time::Instant::now()) {
Some(a) => a,
None => continue,
}
}
};
trace!(addr = %addr, "BLE scan result");
stats.record_scan_result();
// Skip if already connected
{
let pool_guard = pool.lock().await;
if pool_guard.contains(&addr.to_transport_addr()) {
pending.resolve(&addr);
continue;
}
}
// Track for retry in case probe fails and scanner doesn't re-fire
let now = tokio::time::Instant::now();
pending.observe(&addr, now);
// Skip if in cooldown, or backed off after consecutive failures
if !pending.is_due(&addr, now) {
continue;
}
// Skip an address already known to belong to a peer we are connected
// to. Without this the loop re-dials every rotated address of a live
// peer once per cooldown, forever: the duplicate is declined so it
// never enters the pool, so the pool-keyed guard above never sees it.
if let Some(node) = known_node_of.get(&addr) {
let still_connected = {
let pool_guard = pool.lock().await;
pool_guard.find_by_node(node).is_some()
};
if still_connected {
pending.resolve(&addr);
continue;
}
// That peer is gone — forget the mapping and probe normally.
known_node_of.remove(&addr);
}
// Record probe time (before attempt, so cooldown applies on failure too)
pending.mark_attempt(&addr, now);
// Need pubkey for probe
let our_pubkey = match local_pubkey {
Some(pk) => pk,
None => {
buffer.add_peer(&addr);
continue;
}
};
// L2CAP connect, at whatever PSM this peer advertised.
let dial_psm = dial_psm_for(&learned_psm, &addr, configured_psm);
// Stamped here so every outcome below can report how long the peer
// took to go from advertisement to conclusion.
let probe_started = tokio::time::Instant::now();
let stream = match tokio::time::timeout(
std::time::Duration::from_millis(connect_timeout_ms),
io.connect(&addr, dial_psm),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
stats.record_connect_error();
let failures = pending.record_failure(&addr, tokio::time::Instant::now());
debug!(
addr = %addr, role = "central", outcome = "connect-error",
psm = dial_psm, discovery_ms = probe_started.elapsed().as_millis() as u64,
failures, error = %e, "BLE probe connect failed"
);
// Costs one retry — see `forget_psm`.
forget_psm(&learned_psm, &addr);
continue;
}
Err(_) => {
stats.record_connect_timeout();
let failures = pending.record_failure(&addr, tokio::time::Instant::now());
debug!(
addr = %addr, role = "central", outcome = "connect-timeout",
psm = dial_psm, discovery_ms = probe_started.elapsed().as_millis() as u64,
failures, "BLE probe connect timeout"
);
forget_psm(&learned_psm, &addr);
continue;
}
};
// Pubkey exchange, then promote connection to pool
let ta = addr.to_transport_addr();
let send_mtu = stream.send_mtu();
let recv_mtu = stream.recv_mtu();
let stream = Arc::new(stream);
// One reader across both phases — see `pubkey_exchange`.
let mut reader = BleStreamRead::new(Arc::clone(&stream), recv_mtu);
match pubkey_exchange(stream.as_ref(), &mut reader, &our_pubkey).await {
Ok(peer_pubkey) => {
debug!(addr = %addr, "BLE probe complete");
let peer_node = NodeAddr::from_pubkey(&peer_pubkey);
// Same duplicate guard as the inbound path, and the same
// arbitration: a peer we already hold a link to — a rotated
// address of it, or the inbound it opened while this probe
// was in flight — keeps that link, and this one is dropped.
let dup = {
let pool_guard = pool.lock().await;
pool_guard.find_by_node(&peer_node)
};
if let Some(existing) = dup
&& existing != ta
{
debug!(
addr = %ta,
role = "central",
outcome = "duplicate-node-decline",
existing = %existing,
discovery_ms = probe_started.elapsed().as_millis() as u64,
"BLE probe: peer already connected on another address, dropping duplicate"
);
stats.record_duplicate_node_decline();
// Remember what this address resolved to, so the next
// cooldown skips it outright rather than paying another
// connect and exchange to reach the same conclusion.
known_node_of.insert(addr.clone(), peer_node);
// Report the peer under the address its live link is on,
// so the node layer is not handed an alias with no
// connection behind it.
let announced = announced_addr(&pool, &peer_node, &addr).await;
buffer.add_peer_with_pubkey(&announced, peer_pubkey);
pending.resolve(&addr);
continue;
}
// Promote connection to pool — no second L2CAP connect needed
let recv_task = tokio::spawn(receive_loop(
reader,
ta.clone(),
Arc::clone(&pool),
packet_tx.clone(),
transport_id,
Arc::clone(&stats),
recv_mtu,
));
let (send_tx, send_rx) = tokio::sync::mpsc::channel(pool::SEND_QUEUE_DEPTH);
let send_task = tokio::spawn(send_loop(
Arc::clone(&stream),
send_rx,
ta.clone(),
Arc::clone(&pool),
Arc::clone(&stats),
));
let conn = BleConnection {
stream,
send_tx,
send_task: Some(send_task),
recv_task: Some(recv_task),
send_mtu,
recv_mtu,
established_at: tokio::time::Instant::now(),
is_static: false,
addr: addr.clone(),
node_addr: Some(peer_node),
};
let mut pool_guard = pool.lock().await;
match pool_guard.insert(ta.clone(), conn) {
Ok(Some(evicted)) => {
stats.record_pool_eviction();
debug!(addr = %ta, evicted = %evicted, "BLE probe promoted (evicted peer)");
}
Ok(None) => {
debug!(
addr = %ta, role = "central", outcome = "connected",
discovery_ms = probe_started.elapsed().as_millis() as u64,
"BLE probe promoted to pool"
);
}
Err(e) => {
stats.record_connection_rejected();
warn!(
addr = %ta, role = "central", outcome = "pool-rejected",
error = %e, "BLE pool full, probe connection dropped"
);
// The connection is dropped with `conn`, so there is
// nothing to report and nothing to resolve. Leaving the
// address in the retry book is the point: a slot may
// free before the peer is advertised again. The inbound
// path already returns here rather than falling through.
continue;
}
}
drop(pool_guard);
stats.record_connection_established();
pending.resolve(&addr);
// Report to node layer for auto-connect / handshake
buffer.add_peer_with_pubkey(&addr, peer_pubkey);
}
Err(e) => {
stats.record_pubkey_exchange_failure();
let failures = pending.record_failure(&addr, tokio::time::Instant::now());
debug!(
addr = %addr, role = "central", outcome = "pubkey-exchange-failed",
discovery_ms = probe_started.elapsed().as_millis() as u64,
failures, error = %e, "BLE probe pubkey exchange failed"
);
}
}
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::framing::build_established_frame;
use io::{MockBleIo, MockBleStream};
use secp256k1::{Secp256k1, SecretKey};
use std::time::Duration;
// ------------------------------------------------------------------
// PendingProbes — the retry/backoff policy for discovered addresses
// ------------------------------------------------------------------
const TEST_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30);
fn probes() -> PendingProbes {
PendingProbes::new(TEST_COOLDOWN)
}
fn a(n: u8) -> BleAddr {
BleAddr::parse(&format!("ble0/AA:BB:CC:DD:EE:{:02X}", n)).unwrap()
}
/// A fresh sighting is dialled straight away — discovery must not wait a
/// cooldown to try a peer it has never met.
#[test]
fn a_newly_seen_address_is_due_immediately() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(1), t0);
assert!(p.is_due(&a(1), t0));
}
/// The regression this policy exists for: an address that keeps failing
/// must be dialled exponentially less often, not once per cooldown for as
/// long as the process lives.
#[test]
fn consecutive_failures_back_an_address_off_exponentially() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(1), t0);
for expected_shift in 0..MAX_PROBE_BACKOFF_SHIFT {
let n = p.record_failure(&a(1), t0);
assert_eq!(n, expected_shift + 1);
let wait = TEST_COOLDOWN * 2u32.pow(expected_shift);
assert!(
!p.is_due(&a(1), t0 + wait - std::time::Duration::from_millis(1)),
"due too early after {n} failures"
);
assert!(p.is_due(&a(1), t0 + wait), "not due after {n} failures");
}
// And the interval stops growing at the ceiling rather than running
// away to hours.
let capped = TEST_COOLDOWN * 2u32.pow(MAX_PROBE_BACKOFF_SHIFT);
for _ in 0..8 {
p.record_failure(&a(1), t0);
assert!(p.is_due(&a(1), t0 + capped));
}
}
/// Under the old policy an address failing every 30 s for 37 minutes was
/// dialled 49 times. Pin the improvement rather than just the formula.
#[test]
fn a_dead_address_is_dialled_a_handful_of_times_an_hour() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(1), t0);
let mut dials = 0;
let mut now = t0;
let deadline = t0 + std::time::Duration::from_secs(37 * 60);
// Tick at the retry interval, exactly as the loop does.
while now <= deadline {
if p.is_due(&a(1), now) {
p.mark_attempt(&a(1), now);
p.record_failure(&a(1), now);
dials += 1;
}
now += TEST_COOLDOWN;
}
assert!(
(1..=10).contains(&dials),
"expected a handful of dials in 37 minutes, got {dials}"
);
}
/// A scanner that re-reports the same address many times a second (which
/// Android does, at roughly 52/min) must not wash the backoff out.
#[test]
fn repeated_sightings_do_not_reset_the_backoff() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(1), t0);
for _ in 0..4 {
p.record_failure(&a(1), t0);
}
let still_blocked = t0 + TEST_COOLDOWN;
for _ in 0..100 {
p.observe(&a(1), still_blocked);
}
assert!(!p.is_due(&a(1), still_blocked));
assert_eq!(p.entries.len(), 1);
}
/// Failing never removes an address. This is what keeps a BlueZ node
/// recoverable: BlueZ emits `DeviceAdded` once per address per discovery
/// session, so an address dropped from the book would never be offered
/// again and the peer behind it would be unreachable for the life of the
/// process.
#[test]
fn failures_never_evict_the_address_itself() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(1), t0);
for _ in 0..500 {
p.record_failure(&a(1), t0);
}
assert_eq!(p.entries.len(), 1);
// Still reachable: once the (capped) backoff elapses it is dialled
// again, so a peer that comes back is picked up without a new sighting.
let capped = TEST_COOLDOWN * 2u32.pow(MAX_PROBE_BACKOFF_SHIFT);
assert_eq!(p.next_due(t0 + capped), Some(a(1)));
}
/// Conclusive outcomes clear the address *and* its failure history, so a
/// peer that reconnects later starts from a clean slate.
#[test]
fn resolving_clears_the_failure_history() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(1), t0);
for _ in 0..5 {
p.record_failure(&a(1), t0);
}
p.resolve(&a(1));
assert!(p.entries.is_empty());
p.observe(&a(1), t0);
assert!(p.is_due(&a(1), t0));
}
/// The head-of-line half of the bug: probing only the first entry let one
/// address monopolise the retry tick. Rotation gives every due address a
/// turn.
#[test]
fn the_retry_tick_rotates_across_due_addresses() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
for n in 0..3 {
p.observe(&a(n), t0);
}
let order: Vec<_> = (0..6).filter_map(|_| p.next_due(t0)).collect();
assert_eq!(order, vec![a(0), a(1), a(2), a(0), a(1), a(2)]);
}
/// A backed-off address is skipped by the tick rather than blocking the
/// addresses behind it.
#[test]
fn a_backed_off_address_does_not_block_the_others() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(0), t0);
p.observe(&a(1), t0);
p.record_failure(&a(0), t0);
assert_eq!(p.next_due(t0), Some(a(1)));
assert_eq!(p.next_due(t0), Some(a(1)));
}
/// Nothing is due when everything is backed off — the tick idles rather
/// than dialling something it just said it would not.
#[test]
fn next_due_yields_nothing_when_all_are_backed_off() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
p.observe(&a(0), t0);
p.record_failure(&a(0), t0);
assert_eq!(p.next_due(t0), None);
}
/// Addresses rotate, so the book is capacity-bounded. Eviction is by
/// failure count, so the entry least likely to have a peer behind it goes
/// first and a healthy address is never displaced by a dead one.
#[test]
fn a_full_book_evicts_the_most_failed_address() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
for n in 0..MAX_PENDING_PROBES as u8 {
p.observe(&a(n), t0);
}
// One entry is much worse than the rest.
for _ in 0..3 {
p.record_failure(&a(7), t0);
}
p.observe(&a(200), t0);
assert_eq!(p.entries.len(), MAX_PENDING_PROBES);
assert!(p.position(&a(7)).is_none(), "the worst entry should go");
assert!(p.position(&a(200)).is_some(), "the new entry should land");
assert!(p.position(&a(0)).is_some(), "healthy entries should stay");
}
/// Pool membership clears entries in bulk on the retry tick.
#[test]
fn connected_addresses_leave_the_book() {
let mut p = probes();
let t0 = tokio::time::Instant::now();
for n in 0..3 {
p.observe(&a(n), t0);
}
p.drop_connected(|addr| addr == &a(1));
assert_eq!(p.entries.len(), 2);
assert!(p.position(&a(1)).is_none());
}
/// The mock backend is a *test* backend. Any target that compiles this
/// module must have a real one behind it, or a release build of it would
/// ship a BLE transport that starts, reports itself up, and never peers.
///
/// The `compile_error!` above is what enforces that in a non-test build —
/// and by construction it cannot fire in a test build, which is exactly
/// the build everybody runs. This closes that gap: the `cfg!` values below
/// are evaluated for the *target*, not for the test profile, so this
/// asserts the same condition the tripwire does, from the one place a
/// developer will actually see it.
#[test]
fn a_target_that_compiles_this_module_has_a_real_backend() {
let has_concrete_backend = cfg!(bluer_available) || cfg!(target_os = "android");
assert!(
has_concrete_backend,
"target {} is `ble_available` but has no concrete `BleIo` backend, \
so a non-test build of it would select the in-memory mock. Add \
its backend and an arm to the `DefaultBleTransport` cascade, or \
drop it from `ble_available` in build.rs.",
std::env::consts::OS,
);
}
/// Deterministic x-only pubkey for exchange tests.
fn test_pubkey(seed: u8) -> [u8; 32] {
let secp = Secp256k1::new();
let sk = SecretKey::from_slice(&[seed; 32]).unwrap();
sk.public_key(&secp).x_only_public_key().0.serialize()
}
/// Handles a receive-loop test needs to observe: the task, the packets
/// it delivers, and the pool it reaps its entry from.
type ReceiveLoopHarness = (
JoinHandle<()>,
tokio::sync::mpsc::Receiver<ReceivedPacket>,
Arc<Mutex<ConnectionPool<Arc<MockBleStream>>>>,
);
/// Wire up a receive loop over one end of a mock stream pair.
fn spawn_receive_loop(local: MockBleStream) -> ReceiveLoopHarness {
let addr = test_addr(2).to_transport_addr();
let pool = Arc::new(Mutex::new(ConnectionPool::new(7)));
let (tx, rx) = tokio::sync::mpsc::channel(16);
let reader = BleStreamRead::new(Arc::new(local), 2048);
let task = tokio::spawn(receive_loop(
reader,
addr,
Arc::clone(&pool),
tx,
TransportId::new(1),
Arc::new(BleStats::new()),
2048,
));
(task, rx, pool)
}
fn test_addr(n: u8) -> BleAddr {
BleAddr {
adapter: "hci0".to_string(),
device: [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, n],
}
}
fn make_transport(
io: MockBleIo,
) -> (
BleTransport<MockBleIo>,
tokio::sync::mpsc::Receiver<ReceivedPacket>,
) {
let (tx, rx) = tokio::sync::mpsc::channel(64);
let config = BleConfig::default();
let transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
(transport, rx)
}
/// **The property the writer task exists to guarantee, on the transport
/// where it mattered most.**
///
/// BLE was the worst of the connection-oriented transports: `send_async`
/// awaited the L2CAP write *while holding the pool mutex*, so a peer that
/// stopped draining blocked not only its own sender but every other BLE
/// operation — connects, evictions, and each receive loop's teardown all
/// queue behind that guard.
///
/// The mock stream's send half is a bounded channel, so a peer that never
/// reads is a peer whose link has stopped draining. This inserts such a
/// connection with a real writer task behind it, pushes far more than
/// either queue holds, and asserts that every call returns promptly and
/// that the pool stays lockable throughout.
#[tokio::test]
async fn a_ble_peer_that_stops_reading_cannot_block_the_sender_or_the_pool() {
let io = MockBleIo::new("hci0", test_addr(1));
let (transport, _rx) = make_transport(io);
// `_deaf` is the far end. Holding it without ever calling `recv` is
// what makes this a stalled link rather than a closed one.
let (near, _deaf) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let stream = Arc::new(near);
let ta = TransportAddr::from_string("AA:BB:CC:DD:EE:02");
let (send_tx, send_rx) = tokio::sync::mpsc::channel(pool::SEND_QUEUE_DEPTH);
let send_task = tokio::spawn(send_loop(
Arc::clone(&stream),
send_rx,
ta.clone(),
Arc::clone(&transport.pool),
Arc::clone(&transport.stats),
));
transport
.pool
.lock()
.await
.insert(
ta.clone(),
BleConnection {
stream,
send_tx,
send_task: Some(send_task),
recv_task: None,
send_mtu: 2048,
recv_mtu: 2048,
established_at: tokio::time::Instant::now(),
is_static: false,
addr: test_addr(2),
node_addr: None,
},
)
.unwrap();
let frame = vec![0xAB; 512];
let mut queued = 0usize;
let mut refused = 0usize;
for _ in 0..512 {
match tokio::time::timeout(Duration::from_secs(2), transport.send_async(&ta, &frame))
.await
{
Ok(Ok(_)) => queued += 1,
Ok(Err(_)) => refused += 1,
Err(_) => panic!(
"send blocked on a BLE peer that stopped reading; the write is back \
on the caller's task"
),
}
// The pool must stay available the whole time. Before the writer
// task this guard was held across the L2CAP write, so a stalled
// link froze every other BLE operation too.
let guard = tokio::time::timeout(Duration::from_millis(100), transport.pool.lock())
.await
.expect("the pool lock must never be held across a BLE write");
drop(guard);
}
assert!(queued > 0, "the first sends must be accepted");
assert!(
refused > 0,
"a BLE peer that never drains must eventually have sends refused rather \
than queued without bound: queued={queued}"
);
}
#[test]
fn test_transport_type() {
let io = MockBleIo::new("hci0", test_addr(1));
let (transport, _rx) = make_transport(io);
assert_eq!(transport.transport_type().name, "ble");
assert!(transport.transport_type().connection_oriented);
assert!(transport.transport_type().reliable);
}
#[test]
fn test_transport_initial_state() {
let io = MockBleIo::new("hci0", test_addr(1));
let (transport, _rx) = make_transport(io);
assert_eq!(transport.state(), TransportState::Configured);
}
#[test]
fn test_transport_default_mtu() {
let io = MockBleIo::new("hci0", test_addr(1));
let (transport, _rx) = make_transport(io);
assert_eq!(transport.mtu(), 2048);
}
#[tokio::test]
async fn test_transport_start_stop() {
let io = MockBleIo::new("hci0", test_addr(1));
let (mut transport, _rx) = make_transport(io);
transport.start_async().await.unwrap();
assert_eq!(transport.state(), TransportState::Up);
transport.stop_async().await.unwrap();
assert_eq!(transport.state(), TransportState::Down);
}
/// `stop_async` has always stopped advertising; it must stop scanning
/// too. Aborting the scan task only stops the transport reading adverts —
/// on a backend whose radio the embedder owns, the radio keeps scanning
/// until it is told, which on a phone costs battery and keeps
/// broadcasting after the feature was switched off.
#[tokio::test]
async fn stop_async_tells_the_backend_to_stop_scanning() {
let io = MockBleIo::new("hci0", test_addr(1));
let config = BleConfig {
adapter: Some("hci0".to_string()),
scan: Some(true),
advertise: Some(false),
accept_connections: Some(false),
..Default::default()
};
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
transport.start_async().await.unwrap();
assert_eq!(transport.io.stop_scan_calls(), 0);
transport.stop_async().await.unwrap();
assert_eq!(
transport.io.stop_scan_calls(),
1,
"stopping the transport must reach the backend's scan"
);
}
#[tokio::test(start_paused = true)]
async fn test_scan_discovers_peers() {
let io = MockBleIo::new("hci0", test_addr(1));
let (mut transport, _rx) = make_transport(io);
transport.start_async().await.unwrap();
// Inject scan results via the I/O mock
transport.io.inject_scan_result(test_addr(2)).await;
transport.io.inject_scan_result(test_addr(3)).await;
// Let scan_probe_loop pick up results and schedule jitter
tokio::task::yield_now().await;
// Advance past max jitter (5s) so probes fire
tokio::time::advance(std::time::Duration::from_secs(6)).await;
// Let the expired entries get processed
tokio::task::yield_now().await;
// Without pubkey set, scan results go to neighbor buffer as bare MACs
let peers = transport.neighbor_buffer.take();
assert_eq!(peers.len(), 2);
}
#[tokio::test(start_paused = true)]
async fn test_scan_deduplicates() {
let io = MockBleIo::new("hci0", test_addr(1));
let (mut transport, _rx) = make_transport(io);
transport.start_async().await.unwrap();
// Same address twice
transport.io.inject_scan_result(test_addr(2)).await;
transport.io.inject_scan_result(test_addr(2)).await;
// Let scan_probe_loop pick up results
tokio::task::yield_now().await;
tokio::time::advance(std::time::Duration::from_secs(6)).await;
tokio::task::yield_now().await;
let peers = transport.neighbor_buffer.take();
assert_eq!(peers.len(), 1);
}
#[test]
fn test_transport_auto_connect_default() {
let io = MockBleIo::new("hci0", test_addr(1));
let (transport, _rx) = make_transport(io);
assert!(!transport.auto_connect());
}
#[test]
fn test_connection_state_none() {
let io = MockBleIo::new("hci0", test_addr(1));
let (transport, _rx) = make_transport(io);
let addr = test_addr(2).to_transport_addr();
assert_eq!(
transport.connection_state_sync(&addr),
ConnectionState::None
);
}
// ------------------------------------------------------------------
// Packet boundary recovery
// ------------------------------------------------------------------
/// Two whole FMP packets delivered in one `recv` must both arrive.
/// Before reframing the tail was silently truncated and lost.
#[tokio::test]
async fn test_receive_loop_splits_coalesced_packets() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let (task, mut rx, _pool) = spawn_receive_loop(local);
let first = build_established_frame(16);
let second = build_established_frame(48);
let mut both = first.clone();
both.extend_from_slice(&second);
peer.send(&both).await.unwrap();
assert_eq!(rx.recv().await.unwrap().data, first);
assert_eq!(rx.recv().await.unwrap().data, second);
task.abort();
}
/// One FMP packet split across three `recv`s arrives once, whole —
/// not as three runts that FMP and Noise would reject.
#[tokio::test]
async fn test_receive_loop_reassembles_fragmented_packet() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let (task, mut rx, _pool) = spawn_receive_loop(local);
let frame = build_established_frame(64);
let third = frame.len() / 3;
peer.send(&frame[..third]).await.unwrap();
peer.send(&frame[third..2 * third]).await.unwrap();
peer.send(&frame[2 * third..]).await.unwrap();
assert_eq!(rx.recv().await.unwrap().data, frame);
assert!(rx.try_recv().is_err(), "no runt packets");
task.abort();
}
/// One `send` per packet still yields one packet per `send`, byte for
/// byte — the boundary-preserving backend regression.
#[tokio::test]
async fn test_receive_loop_passes_through_whole_packets() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let (task, mut rx, _pool) = spawn_receive_loop(local);
let frames: Vec<Vec<u8>> = [8u16, 0, 512]
.iter()
.map(|n| build_established_frame(*n))
.collect();
for f in &frames {
peer.send(f).await.unwrap();
}
for f in &frames {
assert_eq!(&rx.recv().await.unwrap().data, f);
}
task.abort();
}
/// A malformed frame closes the connection and drops it from the pool
/// rather than spinning the loop.
#[tokio::test]
async fn test_receive_loop_drops_connection_on_bad_frame() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let ta = test_addr(2).to_transport_addr();
let (task, _rx, pool) = spawn_receive_loop(local);
// Put a pool entry in place so its removal is observable.
let (parked, _other) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
pool.lock()
.await
.insert(
ta.clone(),
BleConnection {
stream: Arc::new(parked),
send_tx: tokio::sync::mpsc::channel(1).0,
send_task: None,
recv_task: None,
send_mtu: 2048,
recv_mtu: 2048,
established_at: tokio::time::Instant::now(),
is_static: false,
addr: test_addr(2),
node_addr: None,
},
)
.unwrap();
assert!(pool.lock().await.contains(&ta));
// 0x16 is a TLS ClientHello record type; it parses as FMP version 1.
peer.send(&[0x16, 0x03, 0x01, 0x00]).await.unwrap();
// The loop exits and clears the pool entry.
for _ in 0..50 {
if !pool.lock().await.contains(&ta) {
break;
}
tokio::task::yield_now().await;
}
assert!(!pool.lock().await.contains(&ta));
assert!(task.await.is_ok(), "loop exited cleanly");
}
/// A peer that coalesces its first data packet behind the 33-byte
/// pubkey exchange must not lose it at the hand-off to the framer.
#[tokio::test]
async fn test_pubkey_exchange_preserves_coalesced_data() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let local = Arc::new(local);
let mut reader = BleStreamRead::new(Arc::clone(&local), 2048);
let peer_pk = test_pubkey(2);
let frame = build_established_frame(24);
let mut wire = vec![PUBKEY_EXCHANGE_PREFIX];
wire.extend_from_slice(&peer_pk);
wire.extend_from_slice(&frame);
peer.send(&wire).await.unwrap();
let got = pubkey_exchange(local.as_ref(), &mut reader, &test_pubkey(1))
.await
.unwrap();
assert_eq!(got.serialize(), peer_pk);
let packet = read_fmp_packet(&mut reader, 2048).await.unwrap();
assert_eq!(packet, frame);
}
/// A fragmented pubkey exchange completes. The old exact-length `recv`
/// check could never satisfy this.
#[tokio::test]
async fn test_pubkey_exchange_reassembles_fragments() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let local = Arc::new(local);
let mut reader = BleStreamRead::new(Arc::clone(&local), 2048);
let peer_pk = test_pubkey(3);
let mut wire = vec![PUBKEY_EXCHANGE_PREFIX];
wire.extend_from_slice(&peer_pk);
peer.send(&wire[..17]).await.unwrap();
peer.send(&wire[17..]).await.unwrap();
let got = pubkey_exchange(local.as_ref(), &mut reader, &test_pubkey(1))
.await
.unwrap();
assert_eq!(got.serialize(), peer_pk);
}
// ------------------------------------------------------------------
// Node identity vs. rotating link address
// ------------------------------------------------------------------
/// Two pubkeys, returned as `(smaller_node_addr, larger_node_addr)`.
///
/// The cross-probe tie-breaker is decided by `NodeAddr` ordering, so a
/// test that wants a connection admitted has to know which side it is.
fn pubkeys_ordered_by_node_addr() -> ([u8; 32], [u8; 32]) {
let a = test_pubkey(1);
let b = test_pubkey(2);
let na = NodeAddr::from_pubkey(&XOnlyPublicKey::from_slice(&a).unwrap());
let nb = NodeAddr::from_pubkey(&XOnlyPublicKey::from_slice(&b).unwrap());
if na < nb { (a, b) } else { (b, a) }
}
/// Run the peer half of the pubkey exchange over a mock stream end.
async fn peer_side_exchange(peer: &MockBleStream, peer_pubkey: &[u8; 32]) {
let mut msg = [0u8; PUBKEY_EXCHANGE_SIZE];
msg[0] = PUBKEY_EXCHANGE_PREFIX;
msg[1..].copy_from_slice(peer_pubkey);
peer.send(&msg).await.unwrap();
let mut buf = [0u8; PUBKEY_EXCHANGE_SIZE];
let n = peer.recv(&mut buf).await.unwrap();
assert_eq!(n, PUBKEY_EXCHANGE_SIZE);
}
fn identity_test_config() -> BleConfig {
BleConfig {
adapter: Some("hci0".to_string()),
scan: Some(false),
advertise: Some(false),
accept_connections: Some(true),
probe_cooldown_secs: Some(1),
..Default::default()
}
}
/// Let spawned loops make progress.
///
/// Cooperative only: this hands the scheduler control, it does not move
/// the clock. Anything gated on a `tokio::time` timer needs
/// [`wait_for`] instead.
async fn settle() {
for _ in 0..64 {
tokio::task::yield_now().await;
}
}
/// Wait until `cond` holds, or fail the test.
///
/// A fixed number of `yield_now()` calls is not a wait, it is a race
/// against the clock, and it loses whenever a loop under test is parked
/// on a timer rather than on a channel. `scan_probe_loop` is: before it
/// reaches its `select!` it consumes the retry interval's first tick,
/// and tokio rounds a timer deadline up to the next whole millisecond of
/// its wheel — so unless the runtime clock happens to sit exactly on a
/// millisecond boundary, that tick cannot fire until real time crosses
/// the next one. No number of yields makes real time pass, so whether a
/// yield budget covers the gap depends on how long a yield takes on the
/// host: comfortably on a slow one, not at all on a fast one.
///
/// Polling the condition with a sleep between attempts removes the
/// dependency entirely — the sleep is what lets the timer fire, and the
/// condition is what ends the wait. The already-satisfied case still
/// costs only a `settle`, so nothing that passes today gets slower.
async fn wait_for(what: &str, mut cond: impl FnMut() -> bool) {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
settle().await;
if cond() {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"timed out waiting for {what}"
);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
}
/// A second inbound connection from a rotated address for a peer already
/// in the pool is declined, the incumbent link is kept, and the peer is
/// still announced — under the address its live link is on.
#[tokio::test]
async fn test_inbound_rotation_is_declined_and_keeps_the_incumbent() {
let (smaller, larger) = pubkeys_ordered_by_node_addr();
let io = MockBleIo::new("hci0", test_addr(1));
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport =
BleTransport::new(TransportId::new(1), None, identity_test_config(), io, tx);
// We take the larger node address, so the inbound tie-breaker admits
// rather than drops — the duplicate guard is what is under test.
transport.set_local_pubkey(larger);
transport.start_async().await.unwrap();
// First inbound, on link address 2.
let (ours, peer_a) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
transport.io.inject_inbound(ours).await;
peer_side_exchange(&peer_a, &smaller).await;
settle().await;
assert_eq!(transport.pool.lock().await.len(), 1);
assert!(
transport
.pool
.lock()
.await
.contains(&test_addr(2).to_transport_addr())
);
// The same node dials in again after rotating to link address 3.
let (ours2, peer_b) = MockBleStream::pair(test_addr(1), test_addr(3), 2048);
transport.io.inject_inbound(ours2).await;
peer_side_exchange(&peer_b, &smaller).await;
settle().await;
let pool = transport.pool.lock().await;
assert_eq!(pool.len(), 1, "the rotation must not become a second link");
assert!(
pool.contains(&test_addr(2).to_transport_addr()),
"the incumbent link is kept"
);
assert!(!pool.contains(&test_addr(3).to_transport_addr()));
drop(pool);
assert_eq!(transport.stats.snapshot().duplicate_node_declines, 1);
// Discovery names the peer by the address its link is actually on,
// not by the alias the rotation arrived from — otherwise the node
// layer is handed an address with no connection behind it.
let peers = transport.neighbor_buffer.take();
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].addr, test_addr(2).to_transport_addr());
transport.stop_async().await.unwrap();
drop((peer_a, peer_b));
}
/// Once a rotated alias has been resolved to a peer that holds a live
/// link, the scan loop stops paying a connect and exchange to reach that
/// same conclusion every cooldown.
#[tokio::test(start_paused = true)]
async fn test_scan_loop_stops_reprobing_a_resolved_alias() {
use std::sync::Mutex as StdMutex;
let (smaller, larger) = pubkeys_ordered_by_node_addr();
let io = MockBleIo::new("hci0", test_addr(1));
let connects: Arc<StdMutex<Vec<BleAddr>>> = Arc::new(StdMutex::new(Vec::new()));
let (peer_tx, mut peer_rx) = tokio::sync::mpsc::unbounded_channel();
{
let connects = Arc::clone(&connects);
io.set_connect_handler(move |addr, _psm| {
let (ours, theirs) = MockBleStream::pair(test_addr(1), addr.clone(), 2048);
connects.lock().unwrap().push(addr.clone());
peer_tx
.send(theirs)
.map_err(|_| TransportError::ConnectionRefused)?;
Ok(ours)
});
}
// The remote answers every probe with one identity, whichever link
// address the probe went to.
tokio::spawn(async move {
let mut alive = Vec::new();
while let Some(theirs) = peer_rx.recv().await {
peer_side_exchange(&theirs, &larger).await;
alive.push(theirs);
}
});
let config = BleConfig {
scan: Some(true),
accept_connections: Some(false),
..identity_test_config()
};
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
// We take the smaller node address, so our outbound wins the
// tie-breaker and the probe is promoted.
transport.set_local_pubkey(smaller);
transport.start_async().await.unwrap();
transport.io.inject_scan_result(test_addr(2)).await;
settle().await;
assert_eq!(transport.pool.lock().await.len(), 1);
assert_eq!(connects.lock().unwrap().len(), 1);
// The peer rotates to address 3. That probe is paid once and declined.
transport.io.inject_scan_result(test_addr(3)).await;
settle().await;
assert_eq!(connects.lock().unwrap().len(), 2);
assert_eq!(transport.stats.snapshot().duplicate_node_declines, 1);
assert_eq!(transport.pool.lock().await.len(), 1);
// The alias is advertised again after the cooldown expires. It must
// not be dialled a third time: the loop already knows whose it is.
tokio::time::advance(std::time::Duration::from_secs(5)).await;
transport.io.inject_scan_result(test_addr(3)).await;
settle().await;
assert_eq!(
connects.lock().unwrap().len(),
2,
"a resolved alias of a live peer must not be re-dialled"
);
transport.stop_async().await.unwrap();
}
/// A probe the pool refuses is not a connection, and must not be recorded
/// as one. The inbound path already returns on rejection
/// (`admit_inbound`); this pins the outbound probe path to the same shape.
///
/// Reaching the refusal needs `max_connections: 0`. `ConnectionPool::insert`
/// only fails when the pool is full *and* every slot is static, and every
/// BLE connection is built with `is_static: false`, so a non-empty pool
/// always has an evictable slot. That makes this arm unreachable in a
/// default deployment today and reachable the moment anything marks a
/// connection static, which the pool is already written for.
#[tokio::test(start_paused = true)]
async fn a_pool_rejected_probe_is_neither_established_nor_reported() {
use std::sync::Mutex as StdMutex;
let (ours_pk, theirs_pk) = pubkeys_ordered_by_node_addr();
let io = MockBleIo::new("hci0", test_addr(1));
let connects: Arc<StdMutex<Vec<BleAddr>>> = Arc::new(StdMutex::new(Vec::new()));
let (peer_tx, mut peer_rx) = tokio::sync::mpsc::unbounded_channel();
{
let connects = Arc::clone(&connects);
io.set_connect_handler(move |addr, _psm| {
let (mine, theirs) = MockBleStream::pair(test_addr(1), addr.clone(), 2048);
connects.lock().unwrap().push(addr.clone());
peer_tx
.send(theirs)
.map_err(|_| TransportError::ConnectionRefused)?;
Ok(mine)
});
}
tokio::spawn(async move {
let mut alive = Vec::new();
while let Some(theirs) = peer_rx.recv().await {
peer_side_exchange(&theirs, &theirs_pk).await;
alive.push(theirs);
}
});
let config = BleConfig {
scan: Some(true),
accept_connections: Some(false),
max_connections: Some(0),
..identity_test_config()
};
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
transport.set_local_pubkey(ours_pk);
transport.start_async().await.unwrap();
transport.io.inject_scan_result(test_addr(2)).await;
settle().await;
// The dial and the exchange both happened; only the pool refused.
assert_eq!(connects.lock().unwrap().len(), 1, "the peer was dialled");
let snap = transport.stats.snapshot();
assert_eq!(snap.connections_rejected, 1, "the refusal is recorded");
assert_eq!(
snap.connections_established, 0,
"a refused probe is not an established connection"
);
assert_eq!(transport.pool.lock().await.len(), 0);
assert!(
transport.neighbor_buffer.take().is_empty(),
"the node layer must not be handed a peer with no connection behind it"
);
// It stayed in the retry book, so a freed slot can still admit it.
tokio::time::advance(std::time::Duration::from_secs(5)).await;
settle().await;
assert!(
connects.lock().unwrap().len() >= 2,
"a refused address is retried, not resolved away"
);
transport.stop_async().await.unwrap();
}
// ------------------------------------------------------------------
// Per-peer listener PSM
// ------------------------------------------------------------------
/// Every `(address, psm)` the transport tried to dial.
type DialLog = Arc<std::sync::Mutex<Vec<(BleAddr, u16)>>>;
/// A scanning transport whose dials all fail, recording the PSM each was
/// attempted at.
fn psm_probe_transport(
dials: DialLog,
) -> (
BleTransport<MockBleIo>,
tokio::sync::mpsc::Receiver<ReceivedPacket>,
) {
let io = MockBleIo::new("hci0", test_addr(1));
io.set_connect_handler(move |addr, psm| {
dials.lock().unwrap().push((addr.clone(), psm));
Err(TransportError::ConnectionRefused)
});
let config = BleConfig {
adapter: Some("hci0".to_string()),
scan: Some(true),
advertise: Some(false),
accept_connections: Some(false),
probe_cooldown_secs: Some(1),
..Default::default()
};
let (tx, rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
transport.set_local_pubkey(test_pubkey(1));
(transport, rx)
}
/// A peer that advertises its listener PSM is dialled there, not at the
/// configured one — the whole point of learning it.
#[tokio::test(start_paused = true)]
async fn test_advertised_psm_is_dialled() {
let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials));
transport.start_async().await.unwrap();
transport
.io
.inject_scan_advert(io::ScanAdvert::with_psm(test_addr(2), 0x00C1))
.await;
settle().await;
assert_eq!(dials.lock().unwrap().as_slice(), &[(test_addr(2), 0x00C1)]);
transport.stop_async().await.unwrap();
}
/// The node's own dial to a peer — a path probe, an auto-connect to a
/// known address — goes to the advertised PSM too, not only the scan
/// loop's probe. On a platform that assigns listener PSMs the configured
/// value is never the one the peer listens on, so a node dial that used
/// it failed every time, and the only BLE links that ever formed were the
/// scan loop's.
#[tokio::test]
async fn test_a_node_dial_uses_the_advertised_psm() {
let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials));
transport.start_async().await.unwrap();
// What the scan loop would have learned from the peer's advert. Set
// directly: the loop's own probe of that advert is refused by this
// mock and would forget it again, and the loop is not what is under
// test here.
transport
.learned_psm
.lock()
.unwrap()
.insert(test_addr(2), 0x00C1);
transport
.connect_async(&test_addr(2).to_transport_addr())
.await
.unwrap();
settle().await;
assert_eq!(
dials.lock().unwrap().as_slice(),
&[(test_addr(2), 0x00C1)],
"a node dial must go where the peer said it listens"
);
// The refused dial forgot the learned PSM; the next one falls back.
transport
.connect_async(&test_addr(2).to_transport_addr())
.await
.unwrap();
settle().await;
assert_eq!(
dials.lock().unwrap().last(),
Some(&(test_addr(2), DEFAULT_PSM))
);
transport.stop_async().await.unwrap();
}
/// A legacy UUID-only advertiser carries no PSM, so the configured one is
/// used. This is the path every existing peer takes and it must not
/// regress.
#[tokio::test(start_paused = true)]
async fn test_advert_without_a_psm_falls_back_to_the_configured_one() {
let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials));
transport.start_async().await.unwrap();
transport.io.inject_scan_result(test_addr(2)).await;
settle().await;
assert_eq!(
dials.lock().unwrap().as_slice(),
&[(test_addr(2), DEFAULT_PSM)]
);
transport.stop_async().await.unwrap();
}
/// A learned PSM that does not answer is forgotten, so a stale value
/// costs one retry rather than making the peer permanently unreachable.
#[tokio::test(start_paused = true)]
async fn test_a_failed_dial_forgets_the_learned_psm() {
let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials));
transport.start_async().await.unwrap();
transport
.io
.inject_scan_advert(io::ScanAdvert::with_psm(test_addr(2), 0x00C1))
.await;
settle().await;
assert_eq!(dials.lock().unwrap().len(), 1);
// The retry after the cooldown must not repeat the PSM that failed.
tokio::time::advance(std::time::Duration::from_secs(3)).await;
settle().await;
let log = dials.lock().unwrap().clone();
assert!(log.len() >= 2, "the address is retried after the cooldown");
assert_eq!(log[0], (test_addr(2), 0x00C1));
assert!(
log[1..].iter().all(|(_, psm)| *psm == DEFAULT_PSM),
"retries fall back to the configured PSM: {log:?}"
);
transport.stop_async().await.unwrap();
}
/// The advertisement carries the PSM the listener actually bound, not the
/// one that was requested. This is the whole OS-assigned-PSM case, with
/// no platform in the assertion.
#[tokio::test]
async fn test_the_advertised_psm_is_the_one_actually_bound() {
let io = MockBleIo::new("hci0", test_addr(1));
io.set_bound_psm(0x00C1);
let config = BleConfig {
adapter: Some("hci0".to_string()),
scan: Some(false),
advertise: Some(true),
accept_connections: Some(true),
..Default::default()
};
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
transport.start_async().await.unwrap();
assert_ne!(DEFAULT_PSM, 0x00C1, "test setup: the bound PSM differs");
assert_eq!(transport.io.advertised_psm(), Some(0x00C1));
transport.stop_async().await.unwrap();
}
/// A backend that binds what it was asked for advertises that — the BlueZ
/// path, unchanged.
#[tokio::test]
async fn test_a_backend_that_honours_the_request_advertises_it() {
let io = MockBleIo::new("hci0", test_addr(1));
let config = BleConfig {
adapter: Some("hci0".to_string()),
scan: Some(false),
advertise: Some(true),
accept_connections: Some(true),
..Default::default()
};
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
transport.start_async().await.unwrap();
assert_eq!(transport.io.advertised_psm(), Some(DEFAULT_PSM));
transport.stop_async().await.unwrap();
}
/// A peer that opens with something other than the exchange prefix is
/// rejected before the framer ever sees the bytes.
#[tokio::test]
async fn test_pubkey_exchange_rejects_bad_prefix() {
let (peer, local) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
let local = Arc::new(local);
let mut reader = BleStreamRead::new(Arc::clone(&local), 2048);
let mut wire = vec![0xFFu8];
wire.extend_from_slice(&test_pubkey(4));
peer.send(&wire).await.unwrap();
let err = pubkey_exchange(local.as_ref(), &mut reader, &test_pubkey(1))
.await
.unwrap_err();
assert!(matches!(err, TransportError::RecvFailed(_)));
}
// ------------------------------------------------------------------
// Connect outcome counters
// ------------------------------------------------------------------
/// A dial that errors is counted as an error, not as a timeout. The two
/// are different faults and blur into one useless number if merged.
#[tokio::test(start_paused = true)]
async fn test_a_refused_dial_counts_as_an_error_not_a_timeout() {
let dials: DialLog = Arc::new(std::sync::Mutex::new(Vec::new()));
let (mut transport, _rx) = psm_probe_transport(Arc::clone(&dials));
transport.start_async().await.unwrap();
transport.io.inject_scan_result(test_addr(2)).await;
settle().await;
let snap = transport.stats.snapshot();
assert_eq!(snap.connect_errors, 1);
assert_eq!(snap.connect_timeouts, 0);
assert_eq!(snap.connections_established, 0);
transport.stop_async().await.unwrap();
}
/// A peer that connects and then sends a bad exchange is counted as a
/// pubkey-exchange failure — the link came up and produced nothing
/// usable, which is a different fault from never connecting.
#[tokio::test]
async fn test_a_bad_exchange_counts_as_a_pubkey_exchange_failure() {
let io = MockBleIo::new("hci0", test_addr(1));
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport =
BleTransport::new(TransportId::new(1), None, identity_test_config(), io, tx);
transport.set_local_pubkey(test_pubkey(1));
transport.start_async().await.unwrap();
let (ours, peer) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
transport.io.inject_inbound(ours).await;
let mut wire = vec![0xFFu8];
wire.extend_from_slice(&test_pubkey(2));
peer.send(&wire).await.unwrap();
settle().await;
let snap = transport.stats.snapshot();
assert_eq!(snap.pubkey_exchange_failures, 1);
assert_eq!(snap.connections_accepted, 0);
assert_eq!(transport.pool.lock().await.len(), 0);
transport.stop_async().await.unwrap();
}
/// Link arbitration is "first into the pool wins", on either side and
/// whichever side dialled. The node with the smaller address used to
/// stand its inbound down so that its own outbound would win; when that
/// outbound could not succeed it stood a working link down forever.
/// Here the smaller node has nothing: the inbound from the larger is kept.
#[tokio::test]
async fn test_smaller_node_keeps_an_inbound_it_has_no_link_to_replace() {
let (smaller, larger) = pubkeys_ordered_by_node_addr();
let io = MockBleIo::new("hci0", test_addr(1));
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut inbound_side =
BleTransport::new(TransportId::new(1), None, identity_test_config(), io, tx);
inbound_side.set_local_pubkey(smaller);
inbound_side.start_async().await.unwrap();
let (ours, peer) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
inbound_side.io.inject_inbound(ours).await;
peer_side_exchange(&peer, &larger).await;
{
let stats = Arc::clone(&inbound_side.stats);
wait_for("the inbound to be admitted", || {
stats.snapshot().connections_accepted == 1
})
.await;
}
let snap = inbound_side.stats.snapshot();
assert_eq!(snap.connections_accepted, 1, "a working inbound is a link");
assert_eq!(snap.duplicate_node_declines, 0);
assert_eq!(inbound_side.pool.lock().await.len(), 1);
inbound_side.stop_async().await.unwrap();
}
/// The other half of the same rule: the larger node probing outbound
/// used to yield to the smaller's outbound on principle. With no link to
/// yield to, the probe is promoted.
#[tokio::test]
async fn test_larger_node_promotes_a_probe_when_it_holds_no_link() {
let (smaller, larger) = pubkeys_ordered_by_node_addr();
let io = MockBleIo::new("hci0", test_addr(2));
let (peer_tx, mut peer_rx) = tokio::sync::mpsc::unbounded_channel();
io.set_connect_handler(move |addr, _psm| {
let (ours, theirs) = MockBleStream::pair(test_addr(2), addr.clone(), 2048);
peer_tx
.send(theirs)
.map_err(|_| TransportError::ConnectionRefused)?;
Ok(ours)
});
tokio::spawn(async move {
let mut alive = Vec::new();
while let Some(theirs) = peer_rx.recv().await {
peer_side_exchange(&theirs, &smaller).await;
alive.push(theirs);
}
});
let config = BleConfig {
scan: Some(true),
accept_connections: Some(false),
..identity_test_config()
};
let (tx2, _rx2) = tokio::sync::mpsc::channel(64);
let mut outbound_side = BleTransport::new(TransportId::new(2), None, config, io, tx2);
outbound_side.set_local_pubkey(larger);
outbound_side.start_async().await.unwrap();
outbound_side.io.inject_scan_result(test_addr(1)).await;
{
let stats = Arc::clone(&outbound_side.stats);
wait_for("the probe to be promoted", || {
stats.snapshot().connections_established == 1
})
.await;
}
assert_eq!(outbound_side.pool.lock().await.len(), 1);
outbound_side.stop_async().await.unwrap();
}
/// A probe that completes for a peer whose inbound was admitted while
/// the probe was in flight is the second link to that peer, and is
/// declined as such — the same way a rotated address is. This is the
/// simultaneous-dial race, and it resolves without an ordering rule.
#[tokio::test]
async fn test_a_probe_yields_to_an_inbound_admitted_meanwhile() {
let (smaller, larger) = pubkeys_ordered_by_node_addr();
let io = MockBleIo::new("hci0", test_addr(1));
let (peer_tx, mut peer_rx) = tokio::sync::mpsc::unbounded_channel();
io.set_connect_handler(move |addr, _psm| {
let (ours, theirs) = MockBleStream::pair(test_addr(1), addr.clone(), 2048);
peer_tx
.send(theirs)
.map_err(|_| TransportError::ConnectionRefused)?;
Ok(ours)
});
// The peer answers our probe only after its own inbound has landed.
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
let mut alive = Vec::new();
let _ = release_rx.await;
while let Some(theirs) = peer_rx.recv().await {
peer_side_exchange(&theirs, &larger).await;
alive.push(theirs);
}
});
let config = BleConfig {
scan: Some(true),
..identity_test_config()
};
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let mut transport = BleTransport::new(TransportId::new(1), None, config, io, tx);
transport.set_local_pubkey(smaller);
transport.start_async().await.unwrap();
// Our probe goes out to the peer's advertised address and stalls in
// its pubkey exchange...
transport.io.inject_scan_result(test_addr(2)).await;
settle().await;
assert_eq!(transport.pool.lock().await.len(), 0);
// ...while the peer's own dial arrives on another address and is
// admitted.
let (ours, peer) = MockBleStream::pair(test_addr(1), test_addr(3), 2048);
transport.io.inject_inbound(ours).await;
peer_side_exchange(&peer, &larger).await;
{
let stats = Arc::clone(&transport.stats);
wait_for("the inbound to be admitted", || {
stats.snapshot().connections_accepted == 1
})
.await;
}
// Now the probe completes: second link to the same peer, declined.
let _ = release_tx.send(());
{
let stats = Arc::clone(&transport.stats);
wait_for("the probe to be declined", || {
stats.snapshot().duplicate_node_declines == 1
})
.await;
}
let snap = transport.stats.snapshot();
assert_eq!(
snap.connections_established, 0,
"the probe must not displace the inbound"
);
assert_eq!(transport.pool.lock().await.len(), 1);
transport.stop_async().await.unwrap();
}
/// An oversized packet is a caller bug, not a property of the peer's
/// link. Folding it into `send_errors` would make that number useless as
/// evidence.
#[tokio::test]
async fn test_mtu_rejection_does_not_count_as_a_send_error() {
let io = MockBleIo::new("hci0", test_addr(1));
let (tx, _rx) = tokio::sync::mpsc::channel(64);
let transport =
BleTransport::new(TransportId::new(1), None, identity_test_config(), io, tx);
let ta = test_addr(2).to_transport_addr();
let (parked, _peer) = MockBleStream::pair(test_addr(1), test_addr(2), 2048);
transport
.pool
.lock()
.await
.insert(
ta.clone(),
BleConnection {
stream: Arc::new(parked),
send_tx: tokio::sync::mpsc::channel(1).0,
send_task: None,
recv_task: None,
send_mtu: 64,
recv_mtu: 64,
established_at: tokio::time::Instant::now(),
is_static: false,
addr: test_addr(2),
node_addr: None,
},
)
.unwrap();
let err = transport.send_async(&ta, &[0u8; 128]).await.unwrap_err();
assert!(matches!(err, TransportError::MtuExceeded { .. }));
let snap = transport.stats.snapshot();
assert_eq!(snap.mtu_exceeded, 1);
assert_eq!(snap.send_errors, 0);
}
/// The snapshot is the control-socket contract. Pin every key so a field
/// cannot be dropped or renamed without a test saying so.
#[test]
fn test_snapshot_carries_every_counter() {
let value = serde_json::to_value(BleStats::new().snapshot()).unwrap();
let object = value.as_object().unwrap();
let expected = [
"packets_sent",
"bytes_sent",
"packets_recv",
"bytes_recv",
"send_errors",
"recv_errors",
"mtu_exceeded",
"connections_established",
"connections_accepted",
"connections_rejected",
"connect_timeouts",
"connect_errors",
"pubkey_exchange_failures",
"pool_evictions",
"advertisements_sent",
"scan_results",
"duplicate_node_declines",
"handshakes_aborted",
];
for key in expected {
assert!(object.contains_key(key), "snapshot lost `{key}`");
}
assert_eq!(object.len(), expected.len(), "snapshot gained a key");
}
/// A secret/public key pair from a fixed seed.
///
/// The exchange parses the peer's 32 bytes with `XOnlyPublicKey::from_slice`,
/// so arbitrary bytes will not do.
fn test_keypair(seed: u8) -> ([u8; 32], XOnlyPublicKey) {
let secp = secp256k1::Secp256k1::new();
let sk = secp256k1::SecretKey::from_slice(&[seed; 32]).unwrap();
let (xonly, _) = sk.public_key(&secp).x_only_public_key();
(xonly.serialize(), xonly)
}
/// Inject an inbound connection and return the peer end of the link.
///
/// The peer end must be kept alive: dropping it closes the channel, which
/// the mock reports as a zero-length read rather than as silence.
async fn connect_inbound(
transport: &BleTransport<MockBleIo>,
peer: &BleAddr,
) -> io::MockBleStream {
let (inbound, peer_end) = io::MockBleStream::pair(test_addr(1), peer.clone(), 512);
transport.io.inject_inbound(inbound).await;
peer_end
}
/// Complete the peer half of the pubkey exchange.
async fn send_pubkey(stream: &io::MockBleStream, pubkey: &XOnlyPublicKey) {
let mut msg = [0u8; PUBKEY_EXCHANGE_SIZE];
msg[0] = PUBKEY_EXCHANGE_PREFIX;
msg[1..].copy_from_slice(&pubkey.serialize());
stream.send(&msg).await.unwrap();
}
/// Poll the discovery buffer until every wanted address has appeared.
///
/// Asserts on the discovery buffer rather than the pool because the buffer
/// is populated before the cross-probe tie-breaker, which drops an inbound
/// whose NodeAddr sorts above ours and would make the result depend on the
/// keys the test happened to pick. Sleeps rather than yields, so a paused
/// clock advances instead of the runtime staying busy forever.
async fn wait_for_discovered(buffer: &NeighborBuffer, wanted: &[BleAddr]) -> bool {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for _ in 0..20_000 {
for peer in buffer.take() {
if let Some(addr) = peer.addr.as_str() {
seen.insert(addr.to_string());
}
}
if wanted.iter().all(|a| seen.contains(&a.to_string_repr())) {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
false
}
#[tokio::test(start_paused = true)]
async fn a_silent_inbound_peer_does_not_delay_the_next_accept() {
let io = MockBleIo::new("hci0", test_addr(1));
let (mut transport, _rx) = make_transport(io);
let (our_pubkey, _) = test_keypair(1);
transport.set_local_pubkey(our_pubkey);
transport.start_async().await.unwrap();
// A connects and never says anything.
let _silent = connect_inbound(&transport, &test_addr(2)).await;
// B connects behind it and completes the exchange at once.
let good = connect_inbound(&transport, &test_addr(3)).await;
let (_, peer_pubkey) = test_keypair(7);
send_pubkey(&good, &peer_pubkey).await;
let start = tokio::time::Instant::now();
assert!(
wait_for_discovered(&transport.neighbor_buffer, &[test_addr(3)]).await,
"the well-behaved peer was never admitted"
);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(1),
"the well-behaved peer waited {:?} on the silent one's handshake deadline",
elapsed
);
}
#[tokio::test(start_paused = true)]
async fn a_flood_of_silent_connectors_does_not_delay_a_well_behaved_one() {
// The test that breaks what the guard guards: it fails against a bound
// that waits for a slot rather than reclaiming one.
let io = MockBleIo::new("hci0", test_addr(1));
let (mut transport, _rx) = make_transport(io);
let (our_pubkey, _) = test_keypair(1);
transport.set_local_pubkey(our_pubkey);
transport.start_async().await.unwrap();
let mut silent = Vec::new();
for n in 0..INBOUND_HANDSHAKE_INFLIGHT + 1 {
silent.push(connect_inbound(&transport, &test_addr(20 + n as u8)).await);
}
let good = connect_inbound(&transport, &test_addr(3)).await;
let (_, peer_pubkey) = test_keypair(7);
send_pubkey(&good, &peer_pubkey).await;
let start = tokio::time::Instant::now();
assert!(
wait_for_discovered(&transport.neighbor_buffer, &[test_addr(3)]).await,
"the well-behaved peer was never admitted behind the flood"
);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(1),
"the well-behaved peer waited {:?} behind {} silent connectors",
elapsed,
silent.len()
);
assert!(transport.stats.snapshot().handshakes_aborted > 0);
}
#[tokio::test(start_paused = true)]
async fn inbound_peers_within_the_handshake_budget_are_all_admitted() {
// The guard must not red a legitimately clean run.
let io = MockBleIo::new("hci0", test_addr(1));
let (mut transport, _rx) = make_transport(io);
let (our_pubkey, _) = test_keypair(1);
transport.set_local_pubkey(our_pubkey);
transport.start_async().await.unwrap();
let mut peers = Vec::new();
let mut wanted = Vec::new();
for n in 0..INBOUND_HANDSHAKE_INFLIGHT {
let addr = test_addr(40 + n as u8);
let stream = connect_inbound(&transport, &addr).await;
let (_, peer_pubkey) = test_keypair(10 + n as u8);
send_pubkey(&stream, &peer_pubkey).await;
peers.push(stream);
wanted.push(addr);
}
assert!(
wait_for_discovered(&transport.neighbor_buffer, &wanted).await,
"a well-behaved peer inside the budget was not admitted"
);
assert_eq!(transport.stats.snapshot().handshakes_aborted, 0);
}
#[tokio::test(start_paused = true)]
async fn the_pubkey_exchange_send_half_has_a_deadline() {
// The mock's link is a 64-slot channel, so a peer that never reads
// parks our write once it is full — which is what an L2CAP peer that
// stops draining does.
let (ours, _peer) = io::MockBleStream::pair(test_addr(1), test_addr(2), 512);
for _ in 0..64 {
ours.send(&[0u8; 1]).await.unwrap();
}
let (our_pubkey, _) = test_keypair(1);
// `fix/platform-ble` split the read half out; the exchange takes it as
// its own argument now. The deadline under test is on the send half,
// which never reaches a read, so the reader is only here to type-check.
let recv_mtu = ours.recv_mtu();
let ours = Arc::new(ours);
let mut reader = BleStreamRead::new(Arc::clone(&ours), recv_mtu);
let result = tokio::time::timeout(
std::time::Duration::from_secs(60),
pubkey_exchange(ours.as_ref(), &mut reader, &our_pubkey),
)
.await
.expect("the pubkey exchange send half parked with no deadline of its own");
assert!(matches!(result, Err(TransportError::Timeout)));
}
}