From c2a56685690efc63b589e497e2adca6a1e251782 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:04:14 +0200 Subject: [PATCH] Sync cleanups: normalize SyncTypeFlags, single announce-ID path, TODO (#1373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups deferred from the REQUEST_SYNC review (#1371): - SyncTypeFlags.init(rawValue:) now masks to the union of bits that map to a known message type (derived from the bit↔type table, so it tracks new types automatically). Phantom bits from a truncated/garbled flags field — or a type a newer peer added — no longer live in the set as membership no contains() matches yet toData() re-serializes. - GossipSyncManager stored each latest announce as (hex-id string, packet) and diffed announces against the stored string while every other type recomputed the ID via PacketIdUtil. Collapsed the store to just the packet and recompute the ID everywhere, removing the latent dual-path divergence. - Documented the REQUEST_SYNC TLV table and marked fragmentIdFilter (0x06) with a TODO(v2): it's parsed/re-serialized but never populated or honored (reserved for incremental fragment sync) — finish or drop, not silent dead surface. Adds SyncTypeFlags phantom-bit/round-trip tests and a GossipSyncManager test that an announce already in the requester's filter is suppressed (guards the recompute path). Full suite: 1034 tests pass. Co-authored-by: jack Co-authored-by: Claude Opus 4.8 --- bitchat/Models/RequestSyncPacket.swift | 9 +++++ bitchat/Sync/GossipSyncManager.swift | 22 +++++------ bitchat/Sync/SyncTypeFlags.swift | 18 ++++++++- bitchatTests/GossipSyncManagerTests.swift | 38 +++++++++++++++++++ bitchatTests/Sync/SyncTypeFlagsTests.swift | 43 ++++++++++++++++++++++ 5 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 bitchatTests/Sync/SyncTypeFlagsTests.swift diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index 0db8e7aa..b34dd925 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -4,6 +4,15 @@ import Foundation // - 0x01: P (uint8) — Golomb-Rice parameter // - 0x02: M (uint32, big-endian) — hash range (N * 2^P) // - 0x03: data (opaque) — GR bitstream bytes (MSB-first) +// - 0x04: types (bitfield) — SyncTypeFlags of covered message types +// - 0x05: sinceTimestamp (uint64, big-endian) — oldest ts the filter covers +// - 0x06: fragmentIdFilter (utf8) — reserved +// +// TODO(v2): fragmentIdFilter (0x06) is parsed and re-serialized but never +// populated or honored — it's the reserved surface for incremental fragment +// sync (request the missing fragments of one file by ID instead of diffing the +// whole fragment set). Either wire it into buildGcsPayload/_handleRequestSync +// or drop the field; don't leave it as silent dead protocol surface. struct RequestSyncPacket { let p: Int let m: UInt32 diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index c966a7ca..f848be7b 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -90,7 +90,7 @@ final class GossipSyncManager { private var messages = PacketStore() private var fragments = PacketStore() private var fileTransfers = PacketStore() - private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] + private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:] private var archiveDirty = false // Timer @@ -206,9 +206,8 @@ final class GossipSyncManager { removeState(for: sender) return } - let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let sender = PeerID(hexData: packet.senderID) - latestAnnouncementByPeer[sender] = (id: idHex, packet: packet) + latestAnnouncementByPeer[sender] = packet case .message: guard isBroadcastRecipient else { return } guard isPacketFresh(packet) else { return } @@ -312,10 +311,9 @@ final class GossipSyncManager { // keys needed to verify everything else, and there is at most one per // peer, so the resend cost is negligible. if requestedTypes.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer { - let (idHex, pkt) = pair + for (_, pkt) in latestAnnouncementByPeer { guard isPacketFresh(pkt) else { continue } - let idBytes = Data(hexString: idHex) ?? Data() + let idBytes = PacketIdUtil.computeId(pkt) if !mightContain(idBytes) { var toSend = pkt toSend.ttl = 0 @@ -372,8 +370,8 @@ final class GossipSyncManager { private func buildGcsPayload(for types: SyncTypeFlags) -> Data { var candidates: [BitchatPacket] = [] if types.contains(.announce) { - for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) { - candidates.append(pair.packet) + for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) { + candidates.append(pkt) } } if types.contains(.message) { @@ -431,8 +429,8 @@ final class GossipSyncManager { // Periodic cleanup of expired messages and announcements private func cleanupExpiredMessages() { // Remove expired announcements - latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in - isPacketFresh(pair.packet) + latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in + isPacketFresh(pkt) } let messageCountBefore = messages.packets.count @@ -512,8 +510,8 @@ final class GossipSyncManager { let nowMs = UInt64(now.timeIntervalSince1970 * 1000) guard nowMs >= timeoutMs else { return } let cutoff = nowMs - timeoutMs - let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in - pair.packet.timestamp < cutoff ? peerID : nil + let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in + pkt.timestamp < cutoff ? peerID : nil } guard !stalePeerIDs.isEmpty else { return } for peerKey in stalePeerIDs { diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 430485a0..fe796809 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet { let rawValue: UInt64 init(rawValue: UInt64) { - self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes + // Drop any bit that doesn't map to a known message type. Wire data can + // carry up to 8 bytes of flags; without this mask, bits with no type + // (a truncated/garbled field, or a type a newer peer added) would live + // in the set as phantom membership that no `contains` check matches and + // `toData` re-serializes — a meaningless "accepted but does nothing" + // state. Masking here keeps every instance normalized at the source. + self.rawValue = rawValue & SyncTypeFlags.knownTypeMask } + /// Union of every bit that maps to a message type. Derived from the + /// bit↔type table so it tracks automatically when a type is added. + private static let knownTypeMask: UInt64 = { + var mask: UInt64 = 0 + for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil { + mask |= (1 << UInt64(bit)) + } + return mask + }() + private static func bitIndex(for type: MessageType) -> Int? { switch type { case .announce: return 0 diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift index c44de8f6..0e7b35eb 100644 --- a/bitchatTests/GossipSyncManagerTests.swift +++ b/bitchatTests/GossipSyncManagerTests.swift @@ -357,6 +357,44 @@ struct GossipSyncManagerTests { #expect(sentPackets.allSatisfy { $0.isRSR }) } + @Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() async throws { + var config = GossipSyncManager.Config() + config.messageSyncIntervalSeconds = 0 + config.fragmentSyncIntervalSeconds = 0 + config.fileTransferSyncIntervalSeconds = 0 + + let requestSyncManager = RequestSyncManager() + let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager) + let delegate = RecordingDelegate() + manager.delegate = delegate + + let sender = try #require(Data(hexString: "aabbccddeeff0011")) + let announcePacket = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: sender, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(), + signature: nil, + ttl: 1 + ) + manager.onPublicPacketSeen(announcePacket) + + // A filter that already contains the announce's canonical ID must + // suppress the response — this only holds if the responder recomputes + // the ID the same way the filter was built (the dual-path bug would + // diff a stored hex string instead). + let announceID = PacketIdUtil.computeId(announcePacket) + let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01) + let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce) + + let peer = PeerID(str: "FFFFFFFFFFFFFFFF") + manager.handleRequestSync(from: peer, request: request) + // Barrier: the async handler is enqueued, so this sync flush runs after it. + manager._performMaintenanceSynchronously(now: Date()) + #expect(delegate.packets.isEmpty) + } + @Test func handleRequestSyncIsRateLimitedPerPeer() async throws { var config = GossipSyncManager.Config() config.seenCapacity = 5 diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift new file mode 100644 index 00000000..a3ab3cc4 --- /dev/null +++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +import BitFoundation +@testable import bitchat + +struct SyncTypeFlagsTests { + + @Test func knownTypesRoundTripThroughData() throws { + let flags: SyncTypeFlags = [.announce, .message, .fragment, .fileTransfer] + let data = try #require(flags.toData()) + let decoded = try #require(SyncTypeFlags.decode(data)) + #expect(decoded == flags) + } + + @Test func decodeDropsPhantomBits() { + // Bits 8+ map to no message type. They must not survive decode as + // phantom membership. + let phantom = Data([0x00, 0xFF]) // bits 8..15 set, no known type + let decoded = SyncTypeFlags.decode(phantom) + #expect(decoded?.rawValue == 0) + #expect(decoded?.toMessageTypes().isEmpty == true) + } + + @Test func phantomBitsAreStrippedButKnownBitsSurvive() { + // Low byte = announce(0) + message(1); high byte = phantom. + let mixed = Data([0b0000_0011, 0xFF]) + let decoded = SyncTypeFlags.decode(mixed) + #expect(decoded?.contains(.announce) == true) + #expect(decoded?.contains(.message) == true) + // Only the two known bits remain; phantom high byte is gone. + #expect(decoded?.rawValue == 0b0000_0011) + } + + @Test func rawValueInitNormalizesPhantomBits() { + let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF) + // Every known type bit is set; nothing above them survives, so the + // field serializes to a single byte. + #expect(flags.contains(.announce)) + #expect(flags.contains(.fileTransfer)) + let data = flags.toData() + #expect(data?.count == 1) + } +}