mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:05:20 +00:00
* Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix
Remaining findings from the July 7 locked-phone test sessions, all rooted
in reconnect/relaunch behavior:
- All Nostr acks (READ and DELIVERED, direct and geohash) now flow through
the paced queue that previously only throttled direct READ receipts.
Reconnect redelivery produced 8 DELIVERED acks in under a second, which
damus rejects ("noting too much").
- Processed gift-wrap event IDs persist across launches
(NostrProcessedEventStore, wired through MessageDeduplicationService,
debounced writes, wiped on the existing clear/panic paths). NIP-59
randomizes gift-wrap timestamps, so the 24h-lookback DM subscriptions
redeliver the same events every launch; without a cross-launch record
each relaunch reprocessed old PMs and acks — the re-ack bursts and the
"delivered ack for unknown mid" warnings (now debug: a stale ack is
expected occasionally and not actionable).
- Own fragments handed back by sync replay (the deliberate RSR ttl=0
restore path) now re-enter the gossip sync store before the self-drop.
The fragment store is not archived, so after a relaunch our sync filter
did not cover our own fragments and peers re-offered them every 30s
round indefinitely; recording them stops the redelivery after one round
while keeping assembly skipped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Address Codex review on #1398: shared ack pacer, transient clears keep disk record
- Geohash acks are sent through short-lived NostrTransport instances
(makeGeohashNostrTransport creates one per ack), so the per-instance ack
queue never paced a burst. Acks now flow through a pacer shared across
instances: Dependencies.live wires the process-wide sharedAckPacer, and
the default Dependencies init builds an isolated pacer from the same
injected scheduleAfter so tests keep stepping the throttle manually.
- clearNostrCaches() runs on every geohash channel switch, so it no longer
wipes the persisted gift-wrap record (that stays on the clearAll/panic
path). Persistence is now append-merge instead of snapshot-overwrite —
serialized on the store's IO queue — so a transient in-memory clear
between debounced flushes can't shrink the on-disk record either.
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>
103 lines
4.8 KiB
Swift
103 lines
4.8 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// Narrow environment for `BLEFragmentHandler`.
|
|
///
|
|
/// All queue hops (the message-queue entry hop and the collections barrier
|
|
/// around the assembly buffer) live on the `BLEService` side — the entry hop
|
|
/// in `BLEService.handleFragment`, the barrier inside the supplied closures —
|
|
/// keeping the handler queue-agnostic and synchronously testable.
|
|
struct BLEFragmentHandlerEnvironment {
|
|
/// Local peer identity at the time the fragment is handled.
|
|
let localPeerID: () -> PeerID
|
|
/// Tracks broadcast fragments for gossip sync.
|
|
let trackPacketSeen: (BitchatPacket) -> Void
|
|
/// Appends the fragment to the assembly buffer (collections barrier write).
|
|
let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult
|
|
/// Ingress acceptance check for the reassembled inner packet.
|
|
let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool
|
|
/// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed).
|
|
let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void
|
|
}
|
|
|
|
/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking,
|
|
/// assembly-buffer appends, and reassembled-packet validation and re-injection
|
|
/// into the receive pipeline.
|
|
final class BLEFragmentHandler {
|
|
private let environment: BLEFragmentHandlerEnvironment
|
|
|
|
init(environment: BLEFragmentHandlerEnvironment) {
|
|
self.environment = environment
|
|
}
|
|
|
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
|
let env = environment
|
|
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
|
|
|
// Sync replay legitimately hands us our own fragments back (the RSR
|
|
// ttl=0 restore path): after a relaunch the fragment store starts
|
|
// empty, so our sync filter doesn't cover them and peers re-offer
|
|
// them. Record them as seen — the next round's filter then covers
|
|
// them and the redelivery stops — but skip assembly: we authored
|
|
// the original, there is nothing to reassemble.
|
|
if peerID == env.localPeerID() {
|
|
if header.isBroadcastFragment {
|
|
env.trackPacketSeen(packet)
|
|
}
|
|
return
|
|
}
|
|
|
|
if header.isBroadcastFragment {
|
|
env.trackPacketSeen(packet)
|
|
}
|
|
|
|
let assemblyResult = env.appendFragment(header)
|
|
|
|
logFragmentAssemblyResult(assemblyResult)
|
|
|
|
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
|
|
|
|
// Decode the original packet bytes we reassembled, so flags/compression are preserved
|
|
if var originalPacket = BinaryProtocol.decode(reassembled) {
|
|
|
|
// Reassembled packet validation
|
|
let innerSender = PeerID(hexData: originalPacket.senderID)
|
|
if !env.isAcceptedIngressPayload(originalPacket, innerSender) {
|
|
// Cleanup below
|
|
} else {
|
|
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
|
originalPacket.ttl = 0
|
|
env.processReassembledPacket(originalPacket, peerID)
|
|
}
|
|
} else {
|
|
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
|
|
}
|
|
}
|
|
|
|
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
|
|
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
|
|
if started {
|
|
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
|
|
}
|
|
}
|
|
|
|
switch result {
|
|
case let .stored(header, started):
|
|
logStartedIfNeeded(header: header, started: started)
|
|
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
|
|
|
case let .complete(header, _, started):
|
|
logStartedIfNeeded(header: header, started: started)
|
|
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
|
|
|
case let .oversized(header, projectedSize, limit, started):
|
|
logStartedIfNeeded(header: header, started: started)
|
|
SecureLogger.warning(
|
|
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
|
|
category: .security
|
|
)
|
|
}
|
|
}
|
|
}
|