Link layer slice 5: BLELinkEvent — the port has a name, the delegates have their own files (#1551)

* Cohere per-link Noise auth and rebind containment into BLELinkAuthState

The authenticated-link owners, the reconnect revalidation policy, and
the two rebind-containment cooldowns were four loose bleQueue-owned
maps whose invariants lived in call-site discipline: every teardown
path had to remember to retire the proof AND close the revalidation
epoch (the pair appeared seven times), and both cooldowns hand-rolled
the same prune-check-record dance. BLELinkAuthState owns them as whole
transitions — retireLink, retireLinks(ownedBy:), permitRebind,
permitRedundantRetirement — with the ownership question (bleQueue
today, engine after the option-B flip) answered in one place.

No behavior change; the one call-site reordering (redundant retirement
computes the survivor before the cooldown check instead of after) is
outcome-equivalent since the cooldown only ever recorded when a
survivor existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split identity-link bindings out of the physical link store

BLELinkStateStore owned two different kinds of truth: what physical
links exist (CB handles, connect lifecycles, characteristics, stream
assemblers) and who each link belongs to (peer bindings in both roles
plus the preferred-peripheral reverse map for directed sends and fanout
collapse). The bindings now live on BLELinkBindings — same bleQueue
ownership, whole-transition methods, direct tests for the rotation
reverse-map cleanup and the preferred-link survivor repair that were
previously only exercised end to end. Composed operations that need
both truths (remove-with-repair, direct link state, the subscribed-
central snapshot, bind-only-live-links) live on the transport as
explicitly bleQueue-confined helpers.

This is the structural half of the option-B boundary flip
(docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move
to the engine without touching what-links-exist. An audit of every
physical clear/remove found three sites (emergency clear, both
unauthorized branches) that needed explicit binding-clear pairing under
the split — each now clears both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix iOS-gated constructors and preserve containment cooldowns on reset

CI caught what the macOS SwiftPM build cannot see: two #if os(iOS)
sites still passed the peerID field that slice B1 removed from
BLEPeripheralLinkState (willRestoreState in BLEService and
armPendingBackgroundConnects in BLERadioController). Both fixed and
verified with a local iOS simulator xcodebuild.

Codex also caught a real regression: BLELinkAuthState.removeAll()
cleared the rebind/retirement cooldown maps, which the original panic
and emergency reset paths deliberately left alive. A stable
CoreBluetooth UUID must not earn a fresh rebind allowance just because
the session state around it was wiped. removeAll() now clears only the
proofs and revalidation epochs, and BLELinkAuthStateTests pins the
survival invariant along with the other auth-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine

The identity domain (BLELinkBindings + BLELinkAuthState) is now owned
by the engine queue, with a DEBUG dispatchPrecondition trapping any
access from another queue. bleQueue keeps only physical link state.

What changed shape:

- Receive path is sans-I/O: bleQueue decodes frames and hands
  (packet, linkID) up through ingestDecodedPacket (panic lifecycle
  captured at the handoff); attributeAndHandlePacket resolves the
  sender binding, rejects spoofed senders, applies raw-announce
  binding, and records ingress on the engine. Per-link frame order is
  preserved end to end (both queues serial), which supersedes the old
  batch-local TOCTOU binding in the notification path.
- The rotation rebind is one engine slot: containment checks, proof
  retirement, binding flip, reconnect decision, and rotated-identity
  retirement run straight-line; only CoreBluetooth cancels hop to
  bleQueue. The engine->bleQueue->engine ping-pong is gone, along with
  the _test_afterVerifiedDirectRebindEnqueued pause hook — the test
  that used it now asserts the atomicity directly (a paused engine
  wedged the old gate design into a three-queue deadlock).
- Authenticated-send eligibility (notifyOrEnqueueIfAccepted,
  writeOrEnqueueIfAccepted) is checked on the engine, serialized
  against rebinds by construction; only physical admission
  (updateValue/write/backpressure) runs on bleQueue.
- Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline
  in the delegates) + retirePeripheralLinkIdentity (engine hop with
  survivor repair reading liveness via readLinkState). A binding can
  briefly outlive its physical link; liveness queries join against the
  physical store and the queued retirement converges the two.
- Gossip delegate sends enter the engine via onEngine — safe because
  mesh.sync sits above the engine in the sync order (production engine
  code only async-dispatches into the manager).
- checkPeerConnectivity rides an engine slot from the bleQueue
  maintenance tick.

No wire changes. 1,974 tests green (parallel and serial), iOS
simulator build clean, Periphery clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught

SimulatedMesh wires real CoreBluetooth-free BLEService engines
edge-to-edge through the outbound packet tap and _test_ingestFrame
(the production attribution path the B2 flip created), with per-edge
synthetic link IDs and manual-scheduler time. Five multi-node tests
run in ~40ms with no wall-clock waits:

- announce exchange binds simulated links and connects peers
- Noise sessions establish end-to-end (real crypto, both directions)
- a public message relays across a line topology inside a TTL/frame
  budget (storm bound asserted)
- an 8x duplicate flood delivers exactly once
- a panic rotation rebinds the survivor's link exactly once and stays
  — the scenario that previously needed two phones and log archaeology

Fidelity boundary (documented in the harness): no physical links, so
fanout planning and backpressure are not exercised; attribution,
binding, dedup, TTL, relay decisions, and sessions are the real
engine code.

The simulator found a real bug on its first run: the forced-announce
throttle's lastSent survived a panic, so a rotation within
bleForceAnnounceMinIntervalSeconds of the last announce silently
swallowed the new identity's announce — leaving it invisible to the
mesh until the next maintenance cycle. Today's device test only
passed because the previous announce happened to be minutes old.
BLEAnnounceThrottle gains reset(), called from the panic slot so the
rotated identity owes no throttle debt; pinned by a unit test and the
mesh rotation test.

New DEBUG seams: _test_ingestFrame (production ingress attribution),
_test_forceAnnounce, _test_fenceEngine.

1,980 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files

The upward half of the link-layer port is now a type. BLELinkEvent
enumerates everything the bleQueue link layer tells the engine:
frameDecoded plus the four physical lifecycle transitions
(peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded,
allCentralLinksEnded). Every bleQueue→engine crossing goes through
emitLinkEvent into one engine consumer (handleLinkEvent) — the
scattered messageQueue.async identity hops in the delegates collapse
into event emission, and the engine-side retirement/bookkeeping logic
now lives in one switch.

The CoreBluetooth delegate extensions move to their own files as
physical bookkeeping plus event emission:
- BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate +
  CBPeripheralDelegate)
- BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate
  + write accumulation)
BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain
members the role files share flip private→internal; the queue contract
is enforced by the existing DEBUG traps and grep guards, not access
control. (Two of the flips — isAppActive, logBluetoothStatus — only
surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code.
Verified with a local iOS simulator build.)

The simulated mesh now drives lifecycle events through the identical
enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers
drop → identity retirement → last-link peer bookkeeping → re-announce
heal, entirely through the port. New seam _test_resetAnnounceThrottle
models elapsed wall-clock for the throttle (deliberately separate from
_test_forceAnnounce so the panic-rotation test keeps its regression
value: the production panic path must do its own reset). The panic
test's containment re-announces reset throttles explicitly so those
assertions exercise real delivered announces instead of silently
throttled ones. noiseSessionEstablishesEndToEnd gains a bounded
scheduler-time settle loop after a one-in-many parallel-suite flake
(no wall-clock waits).

Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a
formal handle(event)->[Effect] system and further engine-domain file
splits — both would flip the engine's private state to internal for
cosmetic file counts; the effect formalization rides future feature-
module extractions instead.

1,981 tests green, Periphery clean, iOS simulator build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Baseline logBluetoothStatus for the macOS Periphery scan

Its callers are all inside #if os(iOS) (willRestoreState in both role
files plus the app-state handlers), so the macOS-scheme scan sees the
now-internal declaration with zero callers — the same class as the
baselined candidateCount. Verified 1-USR diff; the previously private
mangled variant was already baselined, which is why the pre-split scan
never flagged it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-07-30 09:27:34 +01:00 committed by GitHub
parent cdebdd9347
commit 4226f01503
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1009 additions and 842 deletions

View File

@ -1 +1 @@
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC18logBluetoothStatusyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat18BLERadioControllerC14candidateCountSivp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}

View File

@ -0,0 +1,40 @@
import BitFoundation
import Foundation
/// The upward half of the link-layer port: everything the bleQueue link
/// layer tells the engine, as one enumerable surface with one engine
/// entry point (`BLEService.handleLinkEvent`). CoreBluetooth delegates
/// shrink to physical bookkeeping plus event emission, and the simulated
/// mesh drives the engine through exactly the same seam.
///
/// Naming follows the physical stores: a *peripheral link* is a
/// connection we own as central (keyed by the remote peripheral's UUID);
/// a *central link* is a remote central subscribed to our peripheral role
/// (keyed by its UUID).
enum BLELinkEvent {
/// A decoded frame arrived on a link. Attribution binding lookup,
/// spoof rejection, raw-announce binding, ingress recording is
/// engine work. Emission captures the panic lifecycle at the handoff.
case frameDecoded(BitchatPacket, link: BLEIngressLinkID, linkDescription: String)
/// One peripheral link ended (disconnect, connect failure, or radio
/// policy teardown). The engine retires the link's identity half
/// proof, epoch, binding with survivor repair and, when
/// `runPeerBookkeeping` is set (real disconnects), marks the peer
/// disconnected once its last live link is gone and republishes the
/// peer list.
case peripheralLinkEnded(peripheralID: String, runPeerBookkeeping: Bool)
/// A remote central unsubscribed. The engine retires the central
/// link's identity half and runs last-link peer bookkeeping.
case centralLinkEnded(centralUUID: String)
/// The central role reset and every peripheral link is gone
/// (power-off retires proofs and notifies peers; an authorization
/// loss only drops the bindings).
case allPeripheralLinksEnded(peripheralIDs: [String], retireProofsAndNotify: Bool)
/// The peripheral role reset and every central link is gone (same
/// power-off / authorization-loss split).
case allCentralLinksEnded(centralUUIDs: [String], retireProofsAndNotify: Bool)
}

View File

@ -0,0 +1,433 @@
//
// BLEService+LinkLayerCentralRole.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
// physical bookkeeping (link-state store, buffers, radio policy) and report
// everything else to the engine through the link-event port
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
// MARK: - CBCentralManagerDelegate
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
guard !isPanicSuspended else {
central.stopScan()
restoredPeripherals.forEach {
central.cancelPeripheralConnection($0)
}
return
}
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
SecureLogger.info(
"♻️ Central restore: peripherals=\(restoredPeripherals.count) services=\(restoredServices.count) allowDuplicates=\(String(describing: allowDuplicates))",
category: .session
)
for peripheral in restoredPeripherals {
let identifier = peripheral.identifier.uuidString
peripheral.delegate = self
let existing = linkStateStore.state(forPeripheralID: identifier)
let assembler = existing?.assembler ?? NotificationStreamAssembler()
let characteristic = existing?.characteristic
let wasConnecting = existing?.isConnecting ?? false
let wasConnected = existing?.isConnected ?? false
let restoredState = BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: characteristic,
isConnecting: wasConnecting || peripheral.state == .connecting,
isConnected: wasConnected || peripheral.state == .connected,
lastConnectionAttempt: existing?.lastConnectionAttempt,
assembler: assembler
)
linkStateStore.setPeripheralState(restoredState, for: identifier)
// Restored peripherals are the freshest wake-on-proximity
// candidates we have after a relaunch without this the cache
// starts empty and backgrounding right after a restore arms
// nothing. Service rediscovery for restored-connected links waits
// for poweredOn: CoreBluetooth drops commands issued during
// restoration (API MISUSE warnings).
radio.recordRecentPeripheral(peripheral, peripheralID: identifier, at: Date())
}
// Via the sampler (not a direct capture): it refreshes the cached
// background budget on main first, so the restore log shows the real
// wake window instead of the init sentinel.
logBluetoothStatus("central-restore")
if central.state == .poweredOn {
radio.startScanning()
}
}
#endif
func centralManagerDidUpdateState(_ central: CBCentralManager) {
emitTransportEvent(.bluetoothStateUpdated(central.state))
switch central.state {
case .poweredOn:
guard !isPanicSuspended else {
central.stopScan()
return
}
// Links restored as connected have no characteristic in the new
// process; without rediscovery they sit connected-but-unusable
// until the peer disconnects. Runs here (not willRestoreState)
// because commands issued before poweredOn are dropped.
for state in linkStateStore.peripheralStates where state.isConnected
&& state.characteristic == nil
&& state.peripheral.state == .connected {
SecureLogger.info("♻️ Rediscovering services on restored link: \(state.peripheral.identifier.uuidString.prefix(8))", category: .session)
state.peripheral.discoverServices([BLEService.serviceUUID])
}
// Start scanning - use allow duplicates for faster discovery when active
radio.startScanning()
case .poweredOff:
// CoreBluetooth has already transitioned out of poweredOn. Do
// not issue stop/cancel commands now; they are rejected as API
// misuse. Retire our link state locally instead.
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
let peripheralIDs = linkStateStore.peripheralStates.map { $0.peripheral.identifier.uuidString }
for peripheralID in peripheralIDs {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
linkStateStore.clearPeripherals()
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: peripheralIDs, retireProofsAndNotify: true))
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
linkStateStore.clearPeripherals()
emitLinkEvent(.allPeripheralLinksEnded(peripheralIDs: [], retireProofsAndNotify: false))
case .unsupported:
// Device doesn't support BLE
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
case .resetting:
// Bluetooth stack is resetting - will get another state update when done
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
case .unknown:
// Initial state before we know the actual state
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
radio.handleDiscovery(peripheral, advertisementData: advertisementData, rssi: RSSI)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard !isPanicSuspended else {
central.cancelPeripheralConnection(peripheral)
return
}
let peripheralID = peripheral.identifier.uuidString
#if os(iOS)
// A connect completing while backgrounded is the wake-on-proximity
// path doing its job worth an info line for field verification.
if !isAppActive {
SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session)
}
#endif
// Update state to connected
linkStateStore.markConnected(peripheral)
// Reset backoff state on success
radio.recordConnectionSuccess(peripheralID: peripheralID)
SecureLogger.debug("✅ Connected: \(peripheral.name ?? "Unknown") [\(peripheralID)]", category: .session)
// Discover services
peripheral.discoverServices([BLEService.serviceUUID])
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
SecureLogger.debug("📱 Disconnect: \(peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: .session)
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
if error != nil {
radio.recordDisconnectError(peripheralID: peripheralID, at: Date())
}
// Retain the handle: a dropped link is the best wake-on-proximity
// candidate if the app backgrounds before the peer returns.
radio.recordRecentPeripheral(peripheral, peripheralID: peripheralID, at: Date())
#if os(iOS)
// Link lost while backgrounded (peer walked away): re-arm a pending
// connect during this wake window so the peer's return wakes us again.
// Delayed past the disconnect-settle window to avoid reconnect thrash
// at range edge.
if !isAppActive {
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in
guard let self, !self.isAppActive else { return }
// Reserve 0: use the slot this disconnect freed even in a
// dense mesh, so the lost peer can wake us when it returns.
self.radio.armPendingBackgroundConnects(slotReserve: 0)
}
}
#endif
// Physical teardown now; identity retirement and peer-disconnect
// bookkeeping ride the link-event port. The scan restart and
// connect-slot refill below stay on bleQueue they respond to
// the physical drop regardless of remaining logical links.
discardPeripheralLinkPhysical(peripheralID)
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: true))
// Restart scanning with allow duplicates for faster rediscovery
if centralManager?.state == .poweredOn {
// Stop and restart scanning to ensure we get fresh discovery events
centralManager?.stopScan()
bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
self?.radio.startScanning()
}
}
// Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
let peripheralID = peripheral.identifier.uuidString
// Clean up the references: physical now, identity via the port.
discardPeripheralLinkPhysical(peripheralID)
emitLinkEvent(.peripheralLinkEnded(peripheralID: peripheralID, runPeerBookkeeping: false))
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
radio.recordConnectionFailure(peripheralID: peripheralID)
// Try next candidate
bleQueue.async { [weak self] in self?.radio.tryConnectFromQueue() }
}
}
// MARK: - CBPeripheralDelegate
extension BLEService: CBPeripheralDelegate {
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
// Retry service discovery after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
guard peripheral.state == .connected else { return }
peripheral.discoverServices([BLEService.serviceUUID])
}
return
}
guard let services = peripheral.services else {
SecureLogger.warning("⚠️ No services discovered for \(peripheral.name ?? "Unknown")", category: .session)
return
}
guard let service = services.first(where: { $0.uuid == BLEService.serviceUUID }) else {
// Not a BitChat peer - disconnect
centralManager?.cancelPeripheralConnection(peripheral)
return
}
// Discovering BLE characteristics
peripheral.discoverCharacteristics([BLEService.characteristicUUID], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
return
}
guard let characteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) else {
SecureLogger.warning("⚠️ No matching characteristic found for \(peripheral.name ?? "Unknown")", category: .session)
return
}
// Found characteristic
// Log characteristic properties for debugging
var properties: [String] = []
if characteristic.properties.contains(.read) { properties.append("read") }
if characteristic.properties.contains(.write) { properties.append("write") }
if characteristic.properties.contains(.writeWithoutResponse) { properties.append("writeWithoutResponse") }
if characteristic.properties.contains(.notify) { properties.append("notify") }
if characteristic.properties.contains(.indicate) { properties.append("indicate") }
// Characteristic properties: \(properties.joined(separator: ", "))
// Verify characteristic supports reliable writes
if !characteristic.properties.contains(.write) {
SecureLogger.warning("⚠️ Characteristic doesn't support reliable writes (withResponse)!", category: .session)
}
// Store characteristic in our consolidated structure
let peripheralID = peripheral.identifier.uuidString
linkStateStore.updateCharacteristic(characteristic, forPeripheralID: peripheralID)
// Subscribe for notifications
if characteristic.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: characteristic)
SecureLogger.debug("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: .session)
// Send announce after subscription is confirmed (force send for new connection)
engineScheduler.schedule(after: TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
// Try flushing any spooled directed packets now that we have a link
self?.flushDirectedSpool()
}
} else {
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
return
}
guard let data = characteristic.value, !data.isEmpty else {
SecureLogger.warning("⚠️ No data in notification", category: .session)
return
}
bufferNotificationChunk(data, from: peripheral)
}
private func bufferNotificationChunk(_ chunk: Data, from peripheral: CBPeripheral) {
let peripheralUUID = peripheral.identifier.uuidString
var state = linkStateStore.state(forPeripheralID: peripheralUUID) ?? BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
isConnecting: false,
isConnected: peripheral.state == .connected,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
)
var assembler = state.assembler
let result = assembler.append(chunk)
state.assembler = assembler
linkStateStore.setPeripheralState(state, for: peripheralUUID)
for byte in result.droppedPrefixes {
SecureLogger.warning("⚠️ Dropping byte from BLE stream (unexpected prefix \(String(format: "%02x", byte)))", category: .session)
}
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
// Attribution spoof rejection, announce binding, ingress
// recording is engine work now (the engine owns the bindings).
// Frames hop up in decode order; the engine's serial slot ordering
// gives the same same-batch spoof protection the old bleQueue-side
// batch-local binding enforced: an announce that binds this link is
// attributed before every frame that rode behind it.
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
emitLinkEvent(.frameDecoded(
packet,
link: .peripheral(peripheralUUID),
linkDescription: "Peripheral \(peripheralUUID.prefix(8))"
))
}
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
if let error = error {
SecureLogger.error("❌ Write failed to \(peripheral.name ?? peripheral.identifier.uuidString): \(error.localizedDescription)", category: .session)
// Don't retry - just log the error
} else {
SecureLogger.debug("✅ Write confirmed to \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
}
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
guard !isPanicSuspended else { return }
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
}
drainPendingWrites(for: peripheral)
}
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
guard !isPanicSuspended else { return }
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
invalidatedServiceUUIDs: invalidatedServices.map(\.uuid),
cachedServiceUUIDs: peripheral.services?.map(\.uuid)
)
guard shouldRediscover else { return }
let peripheralID = peripheral.identifier.uuidString
linkStateStore.updatePeripheral(peripheralID) {
$0.characteristic = nil
$0.assembler = NotificationStreamAssembler()
}
SecureLogger.debug("🔄 BitChat service changed for \(peripheral.name ?? peripheral.identifier.uuidString), rediscovering", category: .session)
peripheral.discoverServices([BLEService.serviceUUID])
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
guard !isPanicSuspended else { return }
if let error = error {
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
} else {
SecureLogger.debug("🔔 Notification state updated for \(peripheral.name ?? peripheral.identifier.uuidString): \(characteristic.isNotifying ? "ON" : "OFF")", category: .session)
// If notifications are now on, send an announce to ensure this peer knows about us
if characteristic.isNotifying {
// Sending announce after subscription
self.sendAnnounce(forceSend: true)
}
}
}
}
extension BLEService {
static func shouldRediscoverBitChatService(
invalidatedServiceUUIDs: [CBUUID],
cachedServiceUUIDs: [CBUUID]?
) -> Bool {
invalidatedServiceUUIDs.contains(serviceUUID) || cachedServiceUUIDs?.contains(serviceUUID) != true
}
}

View File

@ -0,0 +1,320 @@
//
// BLEService+LinkLayerPeripheralRole.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CoreBluetooth
import Foundation
// The bleQueue half of the link layer: CoreBluetooth delegate callbacks do
// physical bookkeeping (link-state store, buffers, radio policy) and report
// everything else to the engine through the link-event port
// (BLELinkEvent / emitLinkEvent). See docs/BLE-ARCHITECTURE-V3.md.
// MARK: - CBPeripheralManagerDelegate
extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
switch peripheral.state {
case .poweredOn:
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
// Remove all services first to ensure clean state
peripheral.removeAllServices()
// Create characteristic
characteristic = CBMutableCharacteristic(
type: BLEService.characteristicUUID,
properties: [.notify, .write, .writeWithoutResponse, .read],
value: nil,
permissions: [.readable, .writeable]
)
// Create service
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate)
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
peripheral.add(service)
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
// Clear subscribed centrals (they are now invalid)
let centralIDs = linkStateStore.subscribedCentrals.map { $0.identifier.uuidString }
pendingNotifications.removeAll()
pendingWriteBuffers.removeAll()
linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: centralIDs, retireProofsAndNotify: true))
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
linkStateStore.clearCentrals()
subscriptionAnnounceLimiter.removeAll()
characteristic = nil
emitLinkEvent(.allCentralLinksEnded(centralUUIDs: [], retireProofsAndNotify: false))
case .unsupported:
// Device doesn't support BLE peripheral role
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
case .resetting:
// Bluetooth stack is resetting
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
case .unknown:
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
}
}
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
peripheral.removeAllServices()
characteristic = nil
return
}
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
SecureLogger.info(
"♻️ Peripheral restore: services=\(restoredServices.count) advertisingDataKeys=\(Array(restoredAdvertisement.keys))",
category: .session
)
// Attempt to recover characteristic from restored services
if characteristic == nil {
if let service = restoredServices.first(where: { $0.uuid == BLEService.serviceUUID }),
let restoredCharacteristic = service.characteristics?.first(where: { $0.uuid == BLEService.characteristicUUID }) as? CBMutableCharacteristic {
characteristic = restoredCharacteristic
}
}
// Via the sampler for a fresh background budget (see central-restore).
logBluetoothStatus("peripheral-restore")
if peripheral.state == .poweredOn && !peripheral.isAdvertising {
peripheral.startAdvertising(BLERadioController.advertisementData())
}
}
#endif
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
guard !isPanicSuspended else {
peripheral.stopAdvertising()
return
}
if let error = error {
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
return
}
SecureLogger.debug("✅ Service added successfully, starting advertising", category: .session)
// Start advertising after service is confirmed added
let adData = BLERadioController.advertisementData()
peripheral.startAdvertising(adData)
SecureLogger.debug("📡 Started advertising (LocalName: \((adData[CBAdvertisementDataLocalNameKey] as? String) != nil ? "on" : "off"), ID: \(myPeerID.id.prefix(8))…)", category: .session)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
guard !isPanicSuspended else { return }
let centralUUID = central.identifier.uuidString
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))", category: .session)
linkStateStore.addSubscribedCentral(central)
// BCH-01-004: Rate-limit subscription-triggered announces to prevent enumeration attacks
let now = Date()
switch subscriptionAnnounceLimiter.decision(for: centralUUID, now: now) {
case .allowed:
break
case let .rateLimited(backoffSeconds, attemptCount, suppressAnnounce):
SecureLogger.warning("🛡️ BCH-01-004: Rate-limited announce for central \(centralUUID.prefix(8))... (backoff: \(Int(backoffSeconds))s, attempts: \(attemptCount))", category: .security)
if suppressAnnounce {
SecureLogger.warning("🚨 BCH-01-004: Possible enumeration attack from central \(centralUUID.prefix(8))... - suppressing announce", category: .security)
return
}
// Still flush directed packets for legitimate mesh operation
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.flushDirectedSpool()
}
return
}
// Send announce to the newly subscribed central after a small delay
engineScheduler.schedule(after: TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed
self?.flushDirectedSpool()
}
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
let centralID = central.identifier.uuidString
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))", category: .session)
// bleQueue: physical retirement now.
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
if !isPanicSuspended, peripheral.isAdvertising == false {
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
peripheral.startAdvertising(BLERadioController.advertisementData())
}
// Identity retirement and peer-disconnect bookkeeping ride the
// link-event port.
emitLinkEvent(.centralLinkEnded(centralUUID: centralID))
}
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
guard !isPanicSuspended else { return }
drainPendingNotifications(logPrefix: "✅ Sent")
}
func logBackpressureSampled(_ message: @autoclosure () -> String) {
notificationBackpressureLogCount += 1
if notificationBackpressureLogCount == 1 ||
notificationBackpressureLogCount.isMultiple(of: TransportConfig.bleBackpressureLogInterval) {
SecureLogger.debug("\(message()) [backpressure event #\(notificationBackpressureLogCount)]", category: .session)
}
}
func drainPendingNotifications(logPrefix: String) {
bleQueue.async { [weak self] in
guard let self = self,
let characteristic = self.characteristic,
!self.pendingNotifications.isEmpty else { return }
let pending = self.pendingNotifications.takeAll()
let sentCount = self.sendPendingNotifications(pending, characteristic: characteristic)
if sentCount > 0 {
self.logBackpressureSampled("\(logPrefix) \(sentCount) pending notifications from retry queue (\(self.pendingNotifications.count) still pending)")
}
}
}
private func sendPendingNotifications(_ pending: [BLEPendingNotification<CBCentral>], characteristic: CBMutableCharacteristic) -> Int {
var sentCount = 0
for (index, notification) in pending.enumerated() {
let success = peripheralManager?.updateValue(
notification.data,
for: characteristic,
onSubscribedCentrals: notification.targets
) ?? false
guard success else {
let remaining = Array(pending.dropFirst(index))
pendingNotifications.prepend(remaining)
logBackpressureSampled("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items")
break
}
sentCount += 1
}
return sentCount
}
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
// Suppress logs for single write requests to reduce noise
if requests.count > 1 {
SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session)
}
// IMPORTANT: Respond immediately to prevent timeouts!
// We must respond within a few milliseconds or the central will timeout
for request in requests {
peripheral.respond(to: request, withResult: .success)
}
guard !isPanicSuspended else { return }
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
// Combine per-central request values by offset before decoding.
// Process directly on our message queue to match transport context
let grouped = Dictionary(grouping: requests, by: { $0.central.identifier.uuidString })
for (centralUUID, group) in grouped {
// Sort by offset ascending
let sorted = group.sorted { $0.offset < $1.offset }
let hasMultiple = sorted.count > 1 || (sorted.first?.offset ?? 0) > 0
let chunks = sorted.compactMap { request -> BLEInboundWriteChunk? in
guard let data = request.value, !data.isEmpty else { return nil }
return BLEInboundWriteChunk(offset: request.offset, data: data)
}
let result = pendingWriteBuffers.append(
chunks: chunks,
for: centralUUID,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
switch result {
case let .decoded(packet, metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
processDecodedCentralWrite(packet, centralUUID: centralUUID, central: sorted[0].central)
case let .waiting(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
case let .oversized(metadata):
logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)
SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))", category: .session)
logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted)
}
}
}
private func logAccumulatedCentralWrite(_ metadata: BLEInboundWriteAppendMetadata, centralUUID: String) {
guard let packetType = metadata.packetType,
packetType != MessageType.announce.rawValue else { return }
SecureLogger.debug(
"📥 Accumulated write from central \(centralUUID.prefix(8))…: size=\(metadata.accumulatedBytes) (+\(metadata.appendedBytes)) bytes (type=\(packetType)), offsets=\(metadata.offsets)",
category: .session
)
}
private func logFailedSingleWriteIfNeeded(hasMultiple: Bool, sortedRequests: [CBATTRequest]) {
guard !hasMultiple, let raw = sortedRequests.first?.value else { return }
let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: .session)
}
private func processDecodedCentralWrite(_ packet: BitchatPacket, centralUUID: String, central: CBCentral) {
// bleQueue: physical bookkeeping only. A writer is a live central
// whether or not it subscribed; track it so directed replies and
// the fanout planner can reach it.
linkStateStore.addSubscribedCentral(central)
// Attribution is engine work (the engine owns the bindings).
emitLinkEvent(.frameDecoded(
packet,
link: .central(centralUUID),
linkDescription: "Central \(centralUUID.prefix(8))"
))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -126,4 +126,18 @@ final class SimulatedMesh {
}
pump()
}
/// Advances scheduler time one second per round until `condition`
/// holds (or the round budget runs out the caller's assertion then
/// reports the real failure). Protocol exchanges normally settle in
/// one or two rounds; under a heavily loaded parallel suite, engine
/// slots can interleave with wall-clock-windowed crypto decisions and
/// need a retry cycle or two more. Deterministic: rounds are scheduler
/// time, never sleeps.
func settleUntil(maxRounds: Int = 20, _ condition: () -> Bool) {
for _ in 0..<maxRounds {
if condition() { return }
advanceTime(by: 1)
}
}
}

View File

@ -35,8 +35,12 @@ struct SimulatedMeshTests {
mesh.connect(0, 1)
mesh.announceAll()
// Handshake initiation and any deferred retries ride engine timers.
mesh.advanceTime(by: 2)
// Handshake initiation and any deferred retries ride engine
// timers; settle until both directions hold (normally 1 round).
mesh.settleUntil {
a.service.canDeliverSecurely(to: b.service.myPeerID)
&& b.service.canDeliverSecurely(to: a.service.myPeerID)
}
#expect(a.service.canDeliverSecurely(to: b.service.myPeerID))
#expect(b.service.canDeliverSecurely(to: a.service.myPeerID))
@ -103,6 +107,39 @@ struct SimulatedMeshTests {
#expect(deliveredOnce)
}
@Test
func linkDropEventRetiresBindingAndReconnectHeals() {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connect(0, 1)
mesh.announceAll()
let bobLinkOnAlice = mesh.linkUUID(from: 1, at: 0)
#expect(a.service._test_centralBinding(bobLinkOnAlice) == b.service.myPeerID)
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
// The link layer reports the drop through the same port
// CoreBluetooth's didUnsubscribe uses: identity retirement and
// last-link peer bookkeeping are engine work.
a.service.emitLinkEvent(.centralLinkEnded(centralUUID: bobLinkOnAlice))
a.service._test_fenceEngine()
#expect(a.service._test_centralBinding(bobLinkOnAlice) == nil)
#expect(!a.service.getConnectedPeers().contains(b.service.myPeerID))
// A fresh announce over the (re-established) link binds and
// reconnects the same heal path a real reconnection drives.
// (The announce throttle runs on wall clock; model elapsed time.)
b.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
mesh.settleUntil {
a.service.getConnectedPeers().contains(b.service.myPeerID)
}
#expect(a.service._test_centralBinding(bobLinkOnAlice) == b.service.myPeerID)
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
}
@Test
func panicRotationRebindsSurvivorExactlyOnceAndStays() {
let mesh = SimulatedMesh()
@ -133,7 +170,10 @@ struct SimulatedMeshTests {
// Containment: further announces (and the rebind cooldown) leave
// the healed binding alone no flip-flop back to the dead ID.
// (Reset the wall-clock announce throttles so these actually send.)
b.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
a.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 0)
mesh.advanceTime(by: 2)
#expect(a.service._test_centralBinding(bobLinkOnAlice) == newBobID)

View File

@ -218,9 +218,28 @@ throughput is nowhere near what one serial queue sustains.
`bleForceAnnounceMinIntervalSeconds` of the last announce left the
new identity invisible until the next maintenance cycle
(`BLEAnnounceThrottle.reset()` now runs in the panic slot).
Remaining from the original slice-C scope: the mechanical delegate
extraction behind explicit LinkEvent/LinkCommand types, and the
formal `handle(event) -> [Effect]` engine shape.
**The upward port is named and the delegates live behind it.**
`BLELinkEvent` (frameDecoded + the four physical lifecycle
transitions) is the enumerable bleQueue→engine surface; every
crossing goes through `emitLinkEvent` into one engine consumer
(`handleLinkEvent`), and the simulated mesh drives lifecycle events
through the identical enum a radio does (see
`linkDropEventRetiresBindingAndReconnectHeals`). The CoreBluetooth
delegate extensions moved to their own files —
`BLEService+LinkLayerCentralRole.swift` /
`BLEService+LinkLayerPeripheralRole.swift` — as physical
bookkeeping plus event emission; the physical-domain members they
share are `internal` with the queue contract enforced by the
existing traps and grep guards rather than access control.
**Deliberately not done:** a formal `handle(event) -> [Effect]`
effect system, and splitting the engine-domain feature handlers
into more files. Both would flip the engine's private state
(noiseService, peerRegistry, the identity domain) to internal for
purely cosmetic file counts — the domains are already uniform
(one queue, one rule set) and mechanically guarded. The effect
formalization should ride actual feature-module extractions when a
feature earns its own module, not precede them.
## What this is not