How BitChat Manages State Across BLE Architecture Layers

BitChat uses a strictly layered BLE stack where each layer owns isolated state on dedicated queues—BLELinkLayer on bleQueue, the mesh engine on messageQueue, and feature modules via handlers—communicating through thread-safe events and commands to eliminate deadlocks.

State management in Bluetooth Low Energy (BLE) applications often becomes complex due to asynchronous hardware events and threading constraints. In the permissionlesstech/bitchat repository, the architecture solves this by enforcing strict state ownership across four distinct layers, ensuring that state is never shared across queue boundaries. This design eliminates deadlocks by construction while maintaining deterministic behavior for mesh networking protocols.

Layered State Ownership in the BitChat BLE Architecture

BitChat organizes its BLE transport into four distinct layers, each with well-defined state responsibilities and dedicated execution contexts. This separation ensures that the mesh engine remains a pure sans-I/O core while the link layer handles all CoreBluetooth interactions.

The BLELinkLayer is the only component that imports CoreBluetooth, making it the sole owner of physical link state. It manages CBPeripheral and CBCharacteristic objects, MTU configuration, write buffers, and notification back-pressure on the dedicated bleQueue (a serial queue for BLE operations). According to the source in BLELinkStateStore.swift lines 37-44, this store captures all physical connection state and provides the assumeOwnership method to trap illegal access attempts in debug builds.

Mesh Engine and Protocol State

Above the link layer sits the mesh engine, a pure computation layer that serializes all mesh protocol state including codec logic, packet fragmentation, deduplication, relay policy, and Noise cryptography orchestration. The engine maintains engine-confined stores such as BLELinkAuthState, BLELinkBindings, and the deduplication cache, all accessed exclusively on the messageQueue. As implemented in BLEService.swift lines 71-78, this queue isolates the engine from I/O, allowing it to process LinkEvent inputs and emit LinkCommand outputs as side effects.

Feature Modules and Domain State

Feature modules—including Courier, Board, Private Media, Voice, and Groups—own their specific state stores (e.g., BLEPrivateMediaSessionStore, BLEMeshPingTracker). Rather than running on separate threads, these modules register handlers (BLEFragmentHandler, BLEPublicMessageHandler, etc.) that execute on the engine queue. This design ensures that module state mutations remain engine-confined while allowing specialized domains to manage their own data structures.

App Boundary Facade

The Transport façade exposes capability protocols (MeshBridgingTransport, PanicResettingTransport, BluetoothStateReporting) to the UI layer. This boundary is stateless; calls from the main thread always hop onto the appropriate queue via onEngine or bleQueue rather than accessing underlying stores directly.

Concurrency Contracts and Queue Isolation

The architecture defines three strict ownership models detailed in the BLE-ARCHITECTURE-V3 design document. These contracts prevent race conditions by ensuring state is never shared across queue boundaries.

Engine-Confined State

All mesh engine state is engine-confined, meaning it mutates only on messageQueue. Callers from other threads use the onEngine method implemented in BLEService.swift lines 96-106 to synchronously enter the engine context. This pattern guarantees that packet processing, cryptographic operations, and registry updates happen atomically relative to the engine's event loop.

BLEQueue-Confined State

The link layer's physical state and any buffers touching CoreBluetooth are bleQueue-confined. Direct access outside bleQueue triggers assertions in debug builds through the assumeOwnership checks. This isolation protects CoreBluetooth's thread-unsafe APIs from concurrent access while allowing the link layer to manage back-pressure and connection scheduling independently.

Lock-Backed Stores for Cross-Queue Reads

Some data, such as peer snapshots, must be readable from the main actor while only the engine mutates it. The BLEPeerRegistryStore implements this pattern using lock protection, as shown in BLEService.swift lines 42-48. The main thread can safely query peer registry data without queue hops, while the engine updates the registry on messageQueue under the same lock.

State Flow Between Architecture Layers

State changes propagate through the stack via explicit events and commands, following a strict sync-edge order of main → engine → bleQueue. No layer ever synchronously waits in the reverse direction, eliminating circular dependencies.

  1. Physical events (peripheral connections, characteristic notifications) arrive on bleQueue.
  2. The link layer updates BLELinkStateStore and emits a LinkEvent upward.
  3. BLEService consumes the event on messageQueue via onEngine, querying or updating engine-confined stores like BLELinkAuthState.
  4. Feature module handlers process decoded packets, updating their specific stores (e.g., BLEPublicMessageHandler updating message state).
  5. Outbound work (writes, notifications) generates LinkCommand objects that descend to the link layer and queue on bleQueue for physical transmission.

Practical Example: Sending a Message Through the Layers

The following Swift code demonstrates how state management operates when sending a public message, showing the queue-hopping contract from UI to physical layer:

// 1. Called from UI (main thread)
chatViewModel.sendMessage("Hello world")
// 2. ChatViewModel forwards to BLEService.sendMessage (still on main)
BLEService.sendMessage(...)

// Inside BLEService.sendMessage:
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
    // 3. Hop onto the engine queue
    messageQueue.async { self.sendMessage(...) }
    return
}

// 4. Engine-confined path – sign packet, dedup, broadcast
let signed = noiseService.signPacket(basePacket)
messageDeduplicator.markProcessed(...)
broadcastPacket(signed)

// 5. broadcastPacket ultimately calls writeOrEnqueue on bleQueue
bleQueue.async { … }   // physical write happens here

This path demonstrates the three-queue contract: UI → messageQueue (engine) → bleQueue (link-layer). Each hop transitions state ownership cleanly, with the engine handling cryptographic signing and deduplication before the link layer manages the physical write buffers.

Key Source Files for State Management

Understanding BitChat's state architecture requires examining these specific implementation files:

Summary

BitChat's BLE architecture achieves deterministic state management across different layers through strict isolation and queue ownership:

  • BLELinkLayer owns all physical state on bleQueue, the only thread touching CoreBluetooth objects.
  • Mesh engine processes all protocol logic on messageQueue using engine-confined stores, operating as a sans-I/O core.
  • Feature modules maintain domain-specific state through handlers executing on the engine queue.
  • Lock-backed stores like BLEPeerRegistryStore provide safe read access for the main actor without breaking queue isolation.
  • Unidirectional sync edges (main → engine → bleQueue) guarantee deadlock-free operation by construction.

Frequently Asked Questions

How does BitChat prevent deadlocks between BLE layers?

BitChat prevents deadlocks by enforcing a strict sync-edge order where state access only flows from main → engine → bleQueue. No layer ever synchronously waits for a layer below it to complete work. Instead, communication happens through asynchronous events (LinkEvent) and commands (LinkCommand), ensuring that callbacks never traverse upward in the stack during synchronous execution.

What is the role of BLELinkStateStore in the architecture?

BLELinkStateStore serves as the single source of truth for physical BLE state, including CBPeripheral connections, CBCharacteristic references, MTU settings, and back-pressure buffers. It operates exclusively on bleQueue and includes assumeOwnership assertions to trap illegal cross-thread access attempts in debug builds, protecting CoreBluetooth's thread-unsafe APIs.

How can the main thread safely read peer registry data without causing race conditions?

The main thread accesses peer data through BLEPeerRegistryStore, which implements a lock-backed store pattern. While the engine mutates registry data on messageQueue under a lock, the main actor can safely query snapshots using the same lock for reading. This design, declared in BLEService.swift lines 42-48, avoids queue hops for UI reads while maintaining engine-queue isolation for writes.

Why does the mesh engine use a sans-I/O design?

The mesh engine uses a sans-I/O design (handling event -> [Effect]) to ensure deterministic testing and thread safety. By removing all direct Bluetooth I/O from the engine and isolating it on messageQueue, the core logic becomes purely functional and testable without hardware dependencies. All side effects—such as writing to characteristics—are emitted as LinkCommand values that the link layer executes asynchronously on bleQueue.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →