Files
bitchat/bitchatTests/Fragmentation/FragmentationTests.swift
T
jackandClaude Opus 4.5 869d766f8d fix: address race condition in continuation-based waiting
Add recheck of message count after acquiring lock and before storing
continuation to prevent race where message arrives between initial
count check and continuation install.

Addresses Codex review feedback on PR #931.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:18:40 -10:00

378 lines
14 KiB
Swift

//
// FragmentationTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import CoreBluetooth
@testable import bitchat
struct FragmentationTests {
private let mockKeychain: MockKeychain
private let mockIdentityManager: MockIdentityManager
private let idBridge: NostrIdentityBridge
init() {
mockKeychain = MockKeychain()
mockIdentityManager = MockIdentityManager(mockKeychain)
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
}
@Test("Reassembly from fragments delivers a public message")
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
// Construct a big packet (3KB) from a remote sender (not our own ID)
let remoteShortID = PeerID(str: "1122334455667788")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
// Use a small fragment size to ensure multiple pieces
let fragments = fragmentPacket(original, fragmentSize: 400)
// Shuffle fragments to simulate out-of-order arrival
let shuffled = fragments.shuffled()
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
for (i, fragment) in shuffled.enumerated() {
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 3_000)
}
@Test("Duplicate fragment does not break reassembly")
func duplicateFragmentDoesNotBreakReassembly() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
var frags = fragmentPacket(original, fragmentSize: 300)
// Duplicate one fragment
if let dup = frags.first {
frags.insert(dup, at: 1)
}
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
for (i, fragment) in frags.enumerated() {
if i > 0 {
try await Task.sleep(for: .milliseconds(5))
}
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
// Wait for delegate callback with proper timeout
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2))
#expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048)
}
@Test("Max-sized file transfer survives reassembly")
func maxSizedFileTransferSurvivesReassembly() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
let remoteID = PeerID(str: "CAFEBABECAFEBABE")
let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes)
let filePacket = BitchatFilePacket(
fileName: "limit.bin",
fileSize: UInt64(fileContent.count),
mimeType: "application/octet-stream",
content: fileContent
)
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
let packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: remoteID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encoded,
signature: nil,
ttl: 7,
version: 2
)
let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false)
#expect(!fragments.isEmpty)
for (i, fragment) in fragments.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteID)
}
}
try await sleep(1.0)
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
if let fileName = message.content.split(separator: " ").last {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
let url = incoming.appendingPathComponent(String(fileName))
try? FileManager.default.removeItem(at: url)
}
}
@Test("Invalid fragment header is ignored")
func invalidFragmentHeaderIsIgnored() async throws {
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
let remoteShortID = PeerID(str: "0011223344556677")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
let fragments = fragmentPacket(original, fragmentSize: 250)
// Corrupt one fragment: make payload too short (header incomplete)
var corrupted = fragments
if !corrupted.isEmpty {
var p = corrupted[0]
p = BitchatPacket(
type: p.type,
senderID: p.senderID,
recipientID: p.recipientID,
timestamp: p.timestamp,
payload: Data([0x00, 0x01, 0x02]), // invalid header
signature: nil,
ttl: p.ttl
)
corrupted[0] = p
}
for (i, fragment) in corrupted.enumerated() {
let delay = 5 * Double(i) * 0.001
Task {
try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
}
}
// Allow async processing
try await sleep(0.5)
// Should not deliver since one fragment is invalid and reassembly can't complete
#expect(capture.publicMessages.isEmpty)
}
}
extension FragmentationTests {
/// Thread-safe delegate that supports awaiting message delivery
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
private var _receivedMessages: [BitchatMessage] = []
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
private var expectedPublicMessageCount: Int = 0
private var expectedReceivedMessageCount: Int = 0
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
lock.lock()
defer { lock.unlock() }
return _publicMessages
}
var receivedMessages: [BitchatMessage] {
lock.lock()
defer { lock.unlock() }
return _receivedMessages
}
func didReceiveMessage(_ message: BitchatMessage) {
lock.lock()
_receivedMessages.append(message)
let count = _receivedMessages.count
let expected = expectedReceivedMessageCount
let continuation = receivedMessageContinuation
lock.unlock()
if count >= expected, let cont = continuation {
lock.lock()
receivedMessageContinuation = nil
lock.unlock()
cont.resume()
}
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
lock.lock()
_publicMessages.append((peerID, nickname, content))
let count = _publicMessages.count
let expected = expectedPublicMessageCount
let continuation = publicMessageContinuation
lock.unlock()
if count >= expected, let cont = continuation {
lock.lock()
publicMessageContinuation = nil
lock.unlock()
cont.resume()
}
}
/// Waits for the specified number of public messages to be received
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
lock.lock()
if _publicMessages.count >= count {
lock.unlock()
return
}
expectedPublicMessageCount = count
lock.unlock()
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
await withCheckedContinuation { continuation in
self.lock.lock()
// Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install
if self._publicMessages.count >= count {
self.lock.unlock()
continuation.resume()
return
}
self.publicMessageContinuation = continuation
self.lock.unlock()
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw CancellationError()
}
try await group.next()
group.cancelAll()
}
}
/// Waits for the specified number of received messages
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
lock.lock()
if _receivedMessages.count >= count {
lock.unlock()
return
}
expectedReceivedMessageCount = count
lock.unlock()
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
await withCheckedContinuation { continuation in
self.lock.lock()
// Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install
if self._receivedMessages.count >= count {
self.lock.unlock()
continuation.resume()
return
}
self.receivedMessageContinuation = continuation
self.lock.unlock()
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw CancellationError()
}
try await group.next()
group.cancelAll()
}
}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func isFavorite(fingerprint: String) -> Bool { false }
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
}
// Helper: build a large message packet (unencrypted public message)
private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket {
let content = String(repeating: "A", count: size)
let payload = Data(content.utf8)
let pkt = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: senderShortHex.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
return pkt
}
// Helper: fragment a packet using the same header format BLEService expects
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
}
let total = UInt16(chunks.count)
var packets: [BitchatPacket] = []
for (i, chunk) in chunks.enumerated() {
var payload = Data()
payload.append(fid)
var idxBE = UInt16(i).bigEndian
var totBE = total.bigEndian
withUnsafeBytes(of: &idxBE) { payload.append(contentsOf: $0) }
withUnsafeBytes(of: &totBE) { payload.append(contentsOf: $0) }
payload.append(packet.type)
payload.append(chunk)
let fpkt = BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: packet.recipientID,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl
)
packets.append(fpkt)
}
return packets
}
}