How BitChat Retries Unroutable Private Messages: The Claim-and-Release Mechanism Explained
BitChat implements a claim-and-release pattern that automatically retries unroutable read receipts by removing failed message IDs from a tracking set when MessageRouter returns false, enabling the next periodic read-scan to attempt retransmission.
BitChat's private messaging protocol ensures reliable delivery of read receipts even when destination peers are temporarily unreachable. The retry mechanism for unroutable private messages operates through a lightweight state-tracking system in PrivateChatManager.swift that prevents duplicate transmissions while guaranteeing eventual delivery.
The Core Challenge: Unreachable Peers in P2P Networks
In decentralized messaging systems, peers frequently go offline or become temporarily unroutable due to network partitions. When BitChat attempts to send a read receipt through the MessageRouter, the transport layer may fail to establish a path to the recipient. Rather than permanently dropping these failed receipts, BitChat employs an elegant state machine that defers transmission until connectivity restores.
How the Claim-and-Release Retry Pattern Works
The retry mechanism centers on three coordinated operations that ensure exactly-once delivery semantics without blocking the UI thread.
Claiming the Receipt ID
Before attempting transmission, the PrivateChatManager claims the receipt by inserting the message ID into a Set<String> called sentReadReceipts. This insertion acts as a distributed lock, preventing duplicate send attempts while an initial routing operation is in progress.
sentReadReceipts.insert(messageID) // claim the receipt
Detecting Routing Failures
The MessageRouter provides a Boolean return value from sendReadReceipt(_:to:) indicating routing success. When this method returns false, signaling that the peer is currently unreachable, the manager immediately releases the claim.
if !router.sendReadReceipt(receipt, to: senderPeerID) {
// Unroutable → remove the claim so the next read‑scan will retry
self?.sentReadReceipts.remove(messageID)
}
This removal makes the receipt eligible for immediate reprocessing without requiring complex polling logic or persistent queue management.
Automatic Retry via Periodic Read-Scans
BitChat triggers "read-scan" cycles whenever the chat view appears or new messages arrive. During these scans, the manager iterates through pending receipts and attempts to send any ID not present in sentReadReceipts. Because unroutable receipts are removed from the set upon failure, they naturally bubble back up for retry during the next scan cycle.
Implementation in PrivateChatManager.swift
The core logic resides in bitchat/Services/PrivateChatManager.swift between lines 64-78. The implementation uses Swift concurrency to ensure thread safety while maintaining responsive UI performance.
if let router = messageRouter {
SecureLogger.debug("… sending READ ack … via router", category: .session)
sentReadReceipts.insert(messageID) // claim the receipt
Task { @MainActor [weak self] in
// `router.sendReadReceipt` returns *false* if the receipt cannot be routed
// (e.g. the peer is currently unreachable)
if !router.sendReadReceipt(receipt, to: senderPeerID) {
// Unroutable → remove the claim so the next read‑scan will retry
self?.sentReadReceipts.remove(messageID)
}
}
}
The @MainActor annotation ensures UI-related logging and state updates occur on the main thread, while the weak self capture prevents retain cycles in the asynchronous Task closure.
Practical Retry Flow Example
When marking messages as read through the public API, the retry mechanism operates transparently:
// Mark messages as read → automatically sends read receipts
await chatManager.markAsRead(from: peerID)
// Internally:
// 1. `sendReadReceipt` claims the receipt ID
// 2. Tries to route via `MessageRouter`
// 3. On failure, the claim is removed → next scan retries
For debugging or testing purposes, you can simulate the retry logic manually:
// Simulated retry flow
let receiptID = "msg-12345"
chatManager.sentReadReceipts.insert(receiptID) // claim
let routed = router.sendReadReceipt(receipt, to: peerID)
if !routed {
// Unroutable → make the receipt eligible for retry
chatManager.sentReadReceipts.remove(receiptID)
// Next read-scan will pick this up automatically
}
Integration with the MessageRouter Interface
The retry mechanism depends on a strict contract with MessageRouter.swift. The sendReadReceipt(_:to:) method must return a Boolean indicating routing feasibility rather than throwing exceptions or blocking indefinitely. This design allows the PrivateChatManager to make immediate decisions about state management without managing complex error hierarchies or timeout logic.
Summary
- Claim-and-release pattern: BitChat inserts message IDs into
sentReadReceiptsbefore routing and removes them only upon confirmed failure, creating a self-healing retry loop. - Failure detection: The
MessageRouter.sendReadReceiptmethod returnsfalsefor unroutable peers, triggering immediate claim removal inPrivateChatManager.swift. - Automatic retry: Periodic read-scans (triggered by UI activity or new messages) automatically pick up unclaimed receipts for retransmission without manual intervention.
- Thread safety: The implementation uses Swift's
@MainActorconcurrency model to safely mutate shared state from asynchronous network callbacks.
Frequently Asked Questions
How does BitChat prevent duplicate read receipts during retries?
BitChat prevents duplicates through the sentReadReceipts set, which acts as a claim mechanism. Once an ID enters this set, subsequent read-scans skip that receipt until the routing attempt completes. Only upon explicit failure (return value false from MessageRouter) does the system remove the ID, making it eligible for exactly one retry attempt.
What triggers a retry for unroutable private messages?
Retries trigger automatically during periodic "read-scans" that execute when the chat view appears or new messages arrive. The system scans for receipts not present in sentReadReceipts; since unroutable receipts are removed from this set immediately upon failure, they become visible to the next scan cycle without requiring explicit retry timers or exponential backoff logic.
Where is the retry logic implemented in the BitChat codebase?
The retry mechanism is implemented in bitchat/Services/PrivateChatManager.swift, specifically between lines 64-78. This file manages the sentReadReceipts state and coordinates with MessageRouter.swift to determine routing feasibility. The boolean return contract from sendReadReceipt(_:to:) drives the claim-release decision tree.
What happens if a peer remains permanently unreachable?
If a peer remains permanently unreachable, the receipt ID will continuously cycle through the claim-release pattern. The ID remains outside sentReadReceipts (due to routing failures), causing each read-scan to attempt transmission again. While this creates redundant network attempts, the lightweight nature of read receipts and the set-based deduplication ensure the system does not accumulate unbounded memory or duplicate deliveries once connectivity eventually restores.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →