Merge pull request #25 from torlando-tech/release/v0.2.2
Some checks failed
Release / Validate Release (push) Has been cancelled
Release / Run Tests (push) Has been cancelled
Release / Run Tests-1 (push) Has been cancelled
Release / Run Tests-2 (push) Has been cancelled
Release / Run Tests-3 (push) Has been cancelled
Release / Build Release Artifacts (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Release v0.2.2
This commit is contained in:
Torlando 2025-12-17 23:58:31 -05:00 committed by GitHub
commit 54ee4048e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 352 additions and 32 deletions

View file

@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.2] - 2025-11-15
### Added
- pipx installation support with automated D-Bus dependency handling
- BlueZ LE-only mode configuration in installer (prevents BR/EDR fallback)
- Scanner watchdog to detect and recover from Bluetooth stack corruption
- Service UUID filtering for more efficient peer discovery
- Pre-built wheel support for Pi Zero W Python 3.13 (saves 20+ min install time)
### Fixed
- **Connection race condition causing "Operation already in progress" errors**
- Added `_connecting_peers` state tracking in `linux_bluetooth_driver.py` to prevent concurrent connection attempts to the same peer
@ -24,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevents BlueZ from maintaining stale connection state after abandoned connection attempts
- Enables successful reconnection after blacklist period expires
- Fixes issue where devices could not reconnect after multiple failed attempts due to corrupted BlueZ state
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 786-830, 980-1069), `src/RNS/Interfaces/BLEInterface.py` (lines 1475-1490)
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py`, `src/RNS/Interfaces/BLEInterface.py`
- **Scanner interference causing "Operation already in progress" errors during connection attempts**
- Added `_should_pause_scanning()` method to check for active connections before starting scanner
@ -34,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevents BlueZ "InProgress" errors from scanner.start() conflicting with connection operations
- Improves connection reliability by eliminating scan-induced connection failures
- Reduces BlueZ error log spam from scan loop
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 539-551, 586-588)
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py`
- Tests: `tests/test_scanner_connection_coordination.py`
- **BR/EDR fallback - clarify ConnectDevice() object path return as success**
@ -44,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Some BlueZ versions report BR/EDR profile unavailable while LE connection succeeds - this is expected
- Improved logging shows object path for debugging visibility
- Clarifies that object path return means success, not error
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 1121-1132)
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py`
- Tests: `tests/test_breddr_fallback_prevention.py`
- **GATT server initialization race causing "Reticulum service not found" errors**
@ -55,9 +64,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Eliminates "service not found" errors during server startup window (typically 50-200ms)
- Graceful degradation: warns if verification times out but doesn't fail startup
- Typical verification time: 100-300ms, no runtime performance impact
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py` (lines 1493-1559, 1527-1538)
- Files: `src/RNS/Interfaces/linux_bluetooth_driver.py`
- Tests: `tests/test_gatt_server_readiness.py`
- D-Bus disconnect monitoring switched to ObjectManager with polling fallback
- Peripheral disconnect cleanup preventing new connections after hitting peer limit
- Identity mapping cleanup on disconnect (prevents stale peer tracking)
- RSSI sentinel value filtering (-127 from BlueZ)
- Columba Android compatibility (filter 1-byte keepalive packets)
### Changed
- Refactored to driver-based architecture (future Windows/macOS/Android support)
## [0.1.1] - 2025-11-10
### Fixed
@ -145,7 +163,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Known Issues
- MAC address randomization can cause connection issues (fixed in v2.2.0)
- Race condition from concurrent connection attempts (fixed in unreleased)
- Race condition from concurrent connection attempts (fixed in v0.2.2)
- BR/EDR fallback on dual-mode devices (fixed in v2.2.0)
---

View file

@ -284,6 +284,26 @@ class BLEInterface(Interface):
super().__init__()
# CRITICAL: Set HW_MTU as instance attribute after super().__init__()
#
# Bug explanation:
# - Base Interface.__init__() sets self.HW_MTU = None
# - BLEInterface.HW_MTU = 500 is a CLASS attribute, not instance
# - After super().__init__(), self.HW_MTU is None (instance shadows class)
# - BLEPeerInterface copies: self.HW_MTU = parent.HW_MTU (gets None)
#
# Impact when HW_MTU is None:
# - Transport.py line ~1855 checks: if packet.receiving_interface.HW_MTU == None
# - If true, it TRUNCATES packet.data by 3 bytes (LINK_MTU_SIZE) before
# passing to Link.validate_request()
# - Link.link_id_from_lr_packet() uses len(packet.data) to compute truncation
# - Since packet.data was pre-truncated, it computes WRONG link_id
# - Link proof's destination_hash won't match pending link's link_id
# - Result: Links time out despite proof arriving correctly
#
# This bug ONLY affects BLE because other interfaces set HW_MTU in __init__
self.HW_MTU = BLEInterface.HW_MTU
# Parse configuration
c = Interface.get_config_obj(configuration)
@ -364,6 +384,7 @@ class BLEInterface(Interface):
self.fragmenters = {} # address -> BLEFragmenter (per MTU)
self.reassemblers = {} # address -> BLEReassembler
self.frag_lock = threading.Lock()
self.pending_mtu = {} # address -> mtu (for MTU/identity race condition)
# Discovery state with prioritization
@ -751,7 +772,7 @@ class BLEInterface(Interface):
role = self.driver.get_peer_role(address)
if peer_identity is not None:
# Central mode: identity provided by driver
# Identity provided by driver (central mode direct, peripheral mode via late callback)
if len(peer_identity) == 16:
identity_hash = self._compute_identity_hash(peer_identity)
@ -759,8 +780,15 @@ class BLEInterface(Interface):
self.address_to_identity[address] = peer_identity
self.identity_to_address[identity_hash] = address
RNS.log(f"{self} connected to {address} as CENTRAL, received identity: {identity_hash}", RNS.LOG_INFO)
role_str = role.upper() if role else "UNKNOWN"
RNS.log(f"{self} connected to {address} as {role_str}, received identity: {identity_hash}", RNS.LOG_INFO)
self._record_connection_success(address)
# Check for pending MTU (race condition: MTU negotiated before identity)
if address in self.pending_mtu:
pending_mtu = self.pending_mtu.pop(address)
RNS.log(f"{self} creating deferred fragmenter for {address} (MTU={pending_mtu})", RNS.LOG_DEBUG)
self._mtu_negotiated_callback(address, pending_mtu)
else:
RNS.log(f"{self} invalid identity from {address} (wrong length), disconnecting", RNS.LOG_WARNING)
self.driver.disconnect(address)
@ -819,7 +847,10 @@ class BLEInterface(Interface):
# Get peer identity
peer_identity = self.address_to_identity.get(address)
if not peer_identity:
RNS.log(f"{self} no identity for {address}, cannot create fragmenter", RNS.LOG_WARNING)
# Race condition: MTU negotiated before identity received
# Store pending MTU and create fragmenter when identity arrives
RNS.log(f"{self} no identity for {address}, storing pending MTU {mtu}", RNS.LOG_DEBUG)
self.pending_mtu[address] = mtu
return
# Create or update fragmenter
@ -980,6 +1011,50 @@ class BLEInterface(Interface):
if frag_key in self.reassemblers:
del self.reassemblers[frag_key]
# Clean up pending MTU (from MTU/identity race condition)
if address in self.pending_mtu:
del self.pending_mtu[address]
def _cleanup_stale_interface(self, identity_hash: str, old_address: str):
"""
Clean up stale interface after MAC rotation.
Called when we detect the same identity at a new MAC address but the
old connection is no longer alive. This allows reconnection to the
peer at their new MAC address.
Args:
identity_hash: 16-character hex hash of the peer's identity
old_address: The old MAC address that is no longer valid
"""
# Get peer identity for fragmenter cleanup
peer_identity = self.address_to_identity.get(old_address)
# Detach and remove old interface
if identity_hash in self.spawned_interfaces:
old_interface = self.spawned_interfaces.pop(identity_hash)
old_interface.detach()
RNS.log(f"{self} detached stale interface for {identity_hash[:8]}", RNS.LOG_DEBUG)
# Clean up address mappings
if identity_hash in self.identity_to_address:
del self.identity_to_address[identity_hash]
# Clean up fragmenter/reassembler for old address
if peer_identity:
frag_key = self._get_fragmenter_key(peer_identity, old_address)
with self.frag_lock:
if frag_key in self.fragmenters:
del self.fragmenters[frag_key]
if frag_key in self.reassemblers:
del self.reassemblers[frag_key]
# Clean up pending MTU for old address
if old_address in self.pending_mtu:
del self.pending_mtu[old_address]
RNS.log(f"{self} cleaned up stale state for {old_address}", RNS.LOG_DEBUG)
def _error_callback(self, severity: str, message: str, exc: Exception = None):
"""
Driver callback: Handle driver errors.
@ -1186,15 +1261,37 @@ class BLEInterface(Interface):
RNS.LOG_DEBUG)
continue
# Protocol v2.2: Skip if interface exists for this identity (any connection type)
# This prevents dual connections (central + peripheral to same peer)
# Protocol v2.2: Skip if interface exists AND is still alive
# This prevents dual connections but allows MAC rotation recovery
peer_identity = self.address_to_identity.get(address)
if peer_identity:
identity_hash = self._compute_identity_hash(peer_identity)
if identity_hash in self.spawned_interfaces:
RNS.log(f"{self} [v2.2] skipping {peer.name} - interface exists for identity {identity_hash[:8]}",
RNS.LOG_DEBUG)
continue
# Check if existing interface is still connected
existing_address = self.identity_to_address.get(identity_hash)
if existing_address and existing_address != address:
# Same identity at different MAC = MAC rotation
# Check if old connection is still alive
if existing_address in self.peers:
# Old connection still active - skip (correct behavior)
RNS.log(f"{self} [v2.2] skipping {peer.name} - already connected via {existing_address[-8:]}",
RNS.LOG_DEBUG)
continue
else:
# Old connection dead - clean up and allow new connection
RNS.log(f"{self} [v2.2] MAC rotation: {identity_hash[:8]} moved from {existing_address[-8:]} to {address[-8:]}, cleaning up stale interface",
RNS.LOG_INFO)
self._cleanup_stale_interface(identity_hash, existing_address)
# Bypass MAC sorting - we must reconnect after MAC rotation
# regardless of which device has the higher MAC address
score = self._score_peer(peer)
scored_peers.append((score, peer))
continue # Skip remaining checks, peer already added
elif existing_address == address:
# Same address, interface exists - skip
RNS.log(f"{self} [v2.2] skipping {peer.name} - interface exists for identity {identity_hash[:8]}",
RNS.LOG_DEBUG)
continue
# Protocol v2.2: MAC address sorting - deterministic connection direction
# Lower MAC initiates (central), higher MAC only accepts (peripheral)

View file

@ -59,11 +59,9 @@ if not hasattr(RNS, 'Identity'):
RNS.Identity = MagicMock()
RNS.Identity.full_hash = lambda x: (x * 2)[:16]
# Mock RNS.Interfaces.Interface (required by BLEInterface.py)
if 'RNS.Interfaces' not in _sys.modules:
rns_interfaces_mock = MagicMock()
_sys.modules['RNS.Interfaces'] = rns_interfaces_mock
# Mock RNS.Interfaces.Interface module (the base class module, not the whole namespace)
# We only mock the Interface.py module, allowing BLEInterface.py to be imported from src/
if 'RNS.Interfaces.Interface' not in _sys.modules:
# Create mock Interface base class
class MockInterface:
MODE_FULL = 1
@ -72,7 +70,40 @@ if 'RNS.Interfaces' not in _sys.modules:
self.OUT = True
self.online = False
rns_interfaces_mock.Interface = MockInterface
@staticmethod
def get_config_obj(configuration):
"""Mock config object wrapper - just returns a dict-like object."""
class ConfigObj:
def __init__(self, config):
self._config = config if config else {}
def __getitem__(self, key):
return self._config.get(key)
def get(self, key, default=None):
return self._config.get(key, default)
def as_string(self, key, default=None):
val = self._config.get(key, default)
return str(val) if val is not None else default
def as_int(self, key, default=None):
val = self._config.get(key, default)
return int(val) if val is not None else default
def as_bool(self, key, default=False):
val = self._config.get(key, default)
if isinstance(val, bool):
return val
if isinstance(val, str):
return val.lower() in ('true', 'yes', '1', 'on')
return bool(val) if val is not None else default
return ConfigObj(configuration)
# Create a mock module for RNS.Interfaces.Interface
interface_module = MagicMock()
interface_module.Interface = MockInterface
_sys.modules['RNS.Interfaces.Interface'] = interface_module
from tests.mock_ble_driver import MockBLEDriver
from RNS.Interfaces.BLEInterface import BLEInterface, DiscoveredPeer
@ -212,10 +243,13 @@ class TestMACEdgeCases:
peers_to_connect = interface._select_peers_to_connect()
peer_addresses = [p.address for p in peers_to_connect]
# Should only connect to peer with lower MAC (00)
assert "AA:BB:CC:DD:EE:00" in peer_addresses
assert "AA:BB:CC:DD:EE:02" not in peer_addresses
assert "AA:BB:CC:DD:EE:FF" not in peer_addresses
# MAC sorting: lower MAC initiates. Our MAC is AA:BB:CC:DD:EE:01
# - AA:BB:CC:DD:EE:00 is LOWER than us, so THEY initiate (we skip)
# - AA:BB:CC:DD:EE:02 is HIGHER than us, so WE initiate
# - AA:BB:CC:DD:EE:FF is HIGHER than us, so WE initiate
assert "AA:BB:CC:DD:EE:00" not in peer_addresses # They initiate
assert "AA:BB:CC:DD:EE:02" in peer_addresses # We initiate
assert "AA:BB:CC:DD:EE:FF" in peer_addresses # We initiate
class TestDualConnectionPrevention:
@ -269,11 +303,12 @@ class TestDualConnectionPrevention:
interface.local_address = driver.local_address
# Add peers with MACs above and below ours
# MAC sorting: lower MAC initiates. Our MAC is 55:55:55:55:55:55
peers_data = [
("11:11:11:11:11:11", -60), # Below (should connect)
("22:22:22:22:22:22", -60), # Below (should connect)
("AA:AA:AA:AA:AA:AA", -60), # Above (should NOT connect)
("FF:FF:FF:FF:FF:FF", -60), # Above (should NOT connect)
("11:11:11:11:11:11", -60), # LOWER than us - THEY initiate, we skip
("22:22:22:22:22:22", -60), # LOWER than us - THEY initiate, we skip
("AA:AA:AA:AA:AA:AA", -60), # HIGHER than us - WE initiate
("FF:FF:FF:FF:FF:FF", -60), # HIGHER than us - WE initiate
]
for addr, rssi in peers_data:
@ -284,11 +319,11 @@ class TestDualConnectionPrevention:
peers_to_connect = interface._select_peers_to_connect()
peer_addresses = [p.address for p in peers_to_connect]
# Should connect to lower MACs only
assert "11:11:11:11:11:11" in peer_addresses
assert "22:22:22:22:22:22" in peer_addresses
assert "AA:AA:AA:AA:AA:AA" not in peer_addresses
assert "FF:FF:FF:FF:FF:FF" not in peer_addresses
# MAC sorting: lower MAC initiates, so we connect to HIGHER MACs
assert "11:11:11:11:11:11" not in peer_addresses # They initiate
assert "22:22:22:22:22:22" not in peer_addresses # They initiate
assert "AA:AA:AA:AA:AA:AA" in peer_addresses # We initiate
assert "FF:FF:FF:FF:FF:FF" in peer_addresses # We initiate
class TestMACParsingErrors:
@ -317,5 +352,175 @@ class TestMACParsingErrors:
pytest.fail(f"Invalid MAC should be handled gracefully: {e}")
class TestMACRotationBypassesSorting:
"""
Test that MAC rotation bypasses MAC sorting.
Bug fix: After MAC rotation cleanup, the peer must be added to the connection
list regardless of MAC sorting. Previously, the code fell through to the MAC
sorting check which could skip the peer if local MAC > peer MAC.
Fix: After _cleanup_stale_interface(), immediately add peer and continue,
bypassing the MAC sorting check.
"""
def test_mac_rotation_bypasses_sorting_when_local_mac_higher(self):
"""
Test that MAC rotation adds peer even when local MAC is higher.
This is the core bug fix test. Without the fix:
- MAC rotation detected, stale interface cleaned up
- Code falls through to MAC sorting check
- Local MAC (FF:...) > Peer MAC (11:...) peer skipped
- Peer interface never recreated!
With the fix:
- MAC rotation detected, stale interface cleaned up
- Peer immediately added, continue (bypass MAC sorting)
- Peer interface recreated correctly
"""
driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") # Higher MAC
owner = MockOwner()
config = {"name": "Test", "enable_central": True}
interface = BLEInterface(owner, config)
interface.driver = driver
interface.local_address = driver.local_address
# Set up MAC rotation scenario:
# - Identity exists at old address
# - Peer discovered at new address (lower MAC)
# - Old connection is stale (not in peers dict)
old_address = "AA:AA:AA:AA:AA:AA"
new_address = "11:22:33:44:55:66" # Lower than local MAC
peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5")
identity_hash = interface._compute_identity_hash(peer_identity)
# Set up existing identity mapping at old address
interface.identity_to_address[identity_hash] = old_address
interface.address_to_identity[new_address] = peer_identity
# Create a mock spawned interface (stale)
mock_peer_interface = MagicMock()
interface.spawned_interfaces[identity_hash] = mock_peer_interface
# old_address NOT in interface.peers (connection is dead/stale)
# Discover peer at new address
peer = DiscoveredPeer(new_address, "RNS-ab5609", -60)
interface.discovered_peers[new_address] = peer
# Select peers to connect
peers_to_connect = interface._select_peers_to_connect()
peer_addresses = [p.address for p in peers_to_connect]
# Even though local MAC > peer MAC, peer should be added due to MAC rotation
assert new_address in peer_addresses, \
"MAC rotation should bypass MAC sorting and add peer"
def test_mac_rotation_cleanup_is_called(self):
"""Test that _cleanup_stale_interface is called during MAC rotation."""
driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF")
owner = MockOwner()
config = {"name": "Test", "enable_central": True}
interface = BLEInterface(owner, config)
interface.driver = driver
interface.local_address = driver.local_address
# Track cleanup calls
cleanup_calls = []
original_cleanup = interface._cleanup_stale_interface
def tracked_cleanup(identity_hash, old_address):
cleanup_calls.append((identity_hash, old_address))
return original_cleanup(identity_hash, old_address)
interface._cleanup_stale_interface = tracked_cleanup
# Set up MAC rotation scenario
old_address = "AA:AA:AA:AA:AA:AA"
new_address = "11:22:33:44:55:66"
peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5")
identity_hash = interface._compute_identity_hash(peer_identity)
interface.identity_to_address[identity_hash] = old_address
interface.address_to_identity[new_address] = peer_identity
mock_peer_interface = MagicMock()
interface.spawned_interfaces[identity_hash] = mock_peer_interface
# Discover peer at new address
peer = DiscoveredPeer(new_address, "RNS-ab5609", -60)
interface.discovered_peers[new_address] = peer
# Select peers
interface._select_peers_to_connect()
# Verify cleanup was called
assert len(cleanup_calls) == 1
assert cleanup_calls[0] == (identity_hash, old_address)
def test_active_connection_prevents_rotation_cleanup(self):
"""Test that active connection prevents MAC rotation cleanup."""
driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF")
owner = MockOwner()
config = {"name": "Test", "enable_central": True}
interface = BLEInterface(owner, config)
interface.driver = driver
interface.local_address = driver.local_address
# Set up scenario where old connection is ACTIVE
old_address = "AA:AA:AA:AA:AA:AA"
new_address = "11:22:33:44:55:66"
peer_identity = bytes.fromhex("ab5609dfffb33b21a102e1ff81196be5")
identity_hash = interface._compute_identity_hash(peer_identity)
interface.identity_to_address[identity_hash] = old_address
interface.address_to_identity[new_address] = peer_identity
mock_peer_interface = MagicMock()
interface.spawned_interfaces[identity_hash] = mock_peer_interface
# OLD connection is ACTIVE (in peers dict)
interface.peers[old_address] = {"mtu": 512}
# Discover peer at new address
peer = DiscoveredPeer(new_address, "RNS-ab5609", -60)
interface.discovered_peers[new_address] = peer
# Select peers
peers_to_connect = interface._select_peers_to_connect()
peer_addresses = [p.address for p in peers_to_connect]
# Should NOT add peer (old connection still active)
assert new_address not in peer_addresses, \
"Active connection should prevent MAC rotation"
def test_normal_mac_sorting_still_works(self):
"""Test that normal MAC sorting still works when no rotation."""
driver = MockBLEDriver(local_address="FF:FF:FF:FF:FF:FF") # Higher MAC
owner = MockOwner()
config = {"name": "Test", "enable_central": True}
interface = BLEInterface(owner, config)
interface.driver = driver
interface.local_address = driver.local_address
# No existing identity mapping - this is a completely new peer
peer_address = "11:22:33:44:55:66" # Lower MAC
peer = DiscoveredPeer(peer_address, "NewPeer", -60)
interface.discovered_peers[peer_address] = peer
# Select peers
peers_to_connect = interface._select_peers_to_connect()
peer_addresses = [p.address for p in peers_to_connect]
# Should NOT add peer (they have lower MAC, they should initiate)
assert peer_address not in peer_addresses, \
"Normal MAC sorting should skip peer with lower MAC"
if __name__ == "__main__":
pytest.main([__file__, "-v"])