How BitChat Reassembles Fragmented Large Messages over BLE: A Deep Dive into the Fragment Assembly Pipeline

BitChat splits payloads exceeding the BLE MTU (~512 bytes) into fragment packets and reassembles them using a three-stage pipeline involving header parsing, bounded in-flight buffer management, and orchestrated re-injection.

BLE's Maximum Transmission Unit (MTU) limits single-packet payloads to approximately 512 bytes. When BitChat messages exceed this threshold, the protocol transparently fragments them at the sender and reassembles them at the receiver. The reassembly logic lives in bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift and BLEFragmentHandler.swift, implementing defensive bounds checking, stalled-broadcast recovery, and protection against self-reassembly attacks.

Core Components of the Fragment Reassembly System

BLEFragmentHeader: Parsing Incoming Fragments

The BLEFragmentHeader struct, defined at lines 9-53 of BLEFragmentAssemblyBuffer.swift, extracts structured metadata from raw BitchatPacket instances. It validates minimum packet size (≥13 bytes) and parses:

  • Sender ID: The originating peer's identifier
  • Fragment ID: An 8-byte unique identifier for this fragmented message
  • Fragment index and total count: Position within the sequence
  • Original packet type: The underlying message type once reassembled
  • Broadcast flag: Whether this fragment belongs to a broadcast message
// Extract header from raw BLE packet
if let header = BLEFragmentHeader(packet: incomingPacket) {
    // header.id, header.index, header.total, header.isBroadcast now available
}

BLEFragmentAssemblyBuffer: Bounded In-Flight Storage

The BLEFragmentAssemblyBuffer class (lines 56-135) manages partial assemblies indexed by BLEFragmentKey (sender + fragment ID). It enforces three critical safety limits:

Limit Purpose Trigger
maxInFlightAssemblies Memory pressure protection Eviction of oldest assembly
Per-type size limit (assemblyLimit(for:)) Prevent oversized allocations .oversized result, assembly dropped
stalledAfter / retryAfter Detect incomplete broadcasts REQUEST_SYNC generation for recovery

The append(_:) method returns three possible outcomes:

let result = buffer.append(header, maxInFlightAssemblies: 8)

switch result {
case .stored(let hdr, let started):
    // Fragment buffered, `started` true if new assembly created
case .complete(let hdr, let data, _):
    // All fragments received; `data` contains concatenated payload
case .oversized(let hdr, let projected, let limit, _):
    // Assembly exceeded size limit and was discarded
}

Fragment storage uses a dictionary-of-arrays structure: fragmentsByKey[key][index]. When a new index arrives, the "last fragment" timestamp refreshes, enabling stalled-broadcast detection.

BLEFragmentHandler: Orchestration and Re-Injection

BLEFragmentHandler.swift (lines 24-76) serves as the entry point for all inbound fragments. Its handle(_:from:) method implements several defensive checks before appending to the buffer:

  1. Self-fragment suppression: If sender == localPeerID, the fragment is tracked (for broadcast gossip sync) but not assembled—preventing a device from reassembling its own data after app relaunch
  2. Gossip sync tracking: Broadcast fragments register via trackPacketSeen for later REQUEST_SYNC rounds
  3. Result logging: logFragmentAssemblyResult emits debug telemetry for each stage

Upon .complete, the handler:

// Decode reassembled bytes using BinaryProtocol
let packet = try BinaryProtocol.decode(data)

// Validate inner sender against accept list
guard isAcceptedIngressPayload(packet, from: header.sender) else { return }

// Zero TTL and re-inject into normal receive pipeline
processReassembledPacket(packet.withZeroTTL())

The Complete Reassembly Flow

The following sequence diagram summarizes the end-to-end flow as implemented in permissionlesstech/bitchat:

  1. Packet receipt — BLE service delivers BitchatPacket to BLEFragmentHandler.handle(_:from:)
  2. Header validation — BLEFragmentHeader(packet:) parses metadata; non-fragments pass through unmodified
  3. Self-origin check — Local fragments tracked but not assembled
  4. Broadcast tracking — trackPacketSeen registers fragment for gossip sync
  5. Buffer append — BLEFragmentAssemblyBuffer.append manages storage, eviction, and completion detection
  6. Completion handling — Raw bytes decoded, validated, TTL zeroed, and re-injected via processReassembledPacket

Stalled-Broadcast Recovery

Broadcast messages present unique challenges: without ACKs, fragments may be lost silently. The buffer's stalledBroadcastFragmentIDs(stalledAfter:retryAfter:) method identifies assemblies with no recent progress:

let stalledIDs = buffer.stalledBroadcastFragmentIDs(
    stalledAfter: 5,   // Seconds since last new fragment
    retryAfter: 10     // Minimum seconds before re-requesting same ID
)
// stalledIDs contains big-endian fragment IDs for REQUEST_SYNC construction

This enables proactive recovery via gossip protocol rounds rather than waiting for timeouts.

Summary

  • BLEFragmentHeader parses 13+ byte packets into structured fragment metadata in BLEFragmentAssemblyBuffer.swift
  • BLEFragmentAssemblyBuffer provides bounded storage with eviction, size limits, and stalled-broadcast detection
  • BLEFragmentHandler orchestrates inbound fragments, suppresses self-reassembly, validates completed packets, and re-injects them into the standard receive pipeline
  • The pipeline protects against memory exhaustion via maxInFlightAssemblies and per-type size limits
  • Broadcast-specific recovery uses timestamp tracking to generate targeted REQUEST_SYNC packets

Frequently Asked Questions

What is the minimum valid size for a BitChat fragment packet?

A fragment packet must be at least 13 bytes to contain a valid BLEFragmentHeader. The BLEFragmentHeader(packet:) initializer returns nil for undersized packets, causing BLEFragmentHandler to pass them through unmodified.

How does BitChat prevent memory exhaustion from incomplete fragment assemblies?

BLEFragmentAssemblyBuffer enforces maxInFlightAssemblies (configurable, typically 8). When this cap is reached, the oldest assembly is evicted before creating a new one. Additionally, per-message-type size limits (assemblyLimit(for:)) trigger immediate eviction of oversized assemblies with a .oversized return value.

Why doesn't BitChat reassemble fragments from itself?

Self-originated fragments are detected when sender == localPeerID. While still tracked for gossip sync (to prevent rebroadcast loops), they are not appended to the assembly buffer. This defends against a relaunched app attempting to reassemble its own previously-transmitted fragments.

How are out-of-order fragments handled during reassembly?

Fragments are stored in fragmentsByKey[key][index] using their declared index position. The buffer does not require sequential arrival; assembly completes when the number of stored fragments equals header.total, regardless of receive order.

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 →