mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 13:05:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0152196ac2 |
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// BLEOriginTTLPolicy.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Chooses the TTL a locally originated public broadcast leaves with.
|
||||
///
|
||||
/// Split out from `BLEService` so the choice is testable without a radio, and so
|
||||
/// the reasoning lives in one place: see
|
||||
/// `TransportConfig.broadcastOriginTTLRange` for why originating at a fixed
|
||||
/// maximum identifies the author to any direct listener.
|
||||
enum BLEOriginTTLPolicy {
|
||||
/// Uniform draw from the configured range.
|
||||
///
|
||||
/// The randomizer is injectable so tests can pin the value; production uses
|
||||
/// the system generator. Note that TTL is excluded from the packet signature
|
||||
/// (`toBinaryDataForSigning` zeroes it so relays can decrement without
|
||||
/// invalidating), so varying it per message is signature-safe and needs no
|
||||
/// cross-platform agreement — a peer running any version simply sees a
|
||||
/// smaller starting TTL and relays it normally.
|
||||
static func originTTL(
|
||||
range: ClosedRange<UInt8> = TransportConfig.broadcastOriginTTLRange,
|
||||
randomTTL: (ClosedRange<UInt8>) -> UInt8 = { UInt8.random(in: $0) }
|
||||
) -> UInt8 {
|
||||
// A degenerate or inverted range must not trap; fall back to the
|
||||
// documented default rather than crashing a send path.
|
||||
guard range.lowerBound <= range.upperBound, range.lowerBound >= 1 else {
|
||||
return TransportConfig.messageTTLDefault
|
||||
}
|
||||
return randomTTL(range)
|
||||
}
|
||||
|
||||
/// Gap after which the next voice frame counts as a new talk burst.
|
||||
static let voiceBurstGap: TimeInterval = 1.0
|
||||
|
||||
/// TTL for a live-voice frame: one draw per talk burst, not per frame.
|
||||
///
|
||||
/// This distinction is the whole value. Voice leaves at roughly 15 frames a
|
||||
/// second, so drawing per frame would hand an observer the maximum of the
|
||||
/// range within a fraction of a second — averaging over a burst would
|
||||
/// defeat the randomisation completely and cost reach for nothing. One draw
|
||||
/// per burst makes a burst a single sample, the same as a text message.
|
||||
///
|
||||
/// Returns the TTL to use and the burst TTL to remember. A burst ends when
|
||||
/// `voiceBurstGap` passes with no frame.
|
||||
static func voiceBurstTTL(
|
||||
now: Date,
|
||||
lastFrameAt: Date?,
|
||||
currentBurstTTL: UInt8?,
|
||||
burstGap: TimeInterval = voiceBurstGap,
|
||||
range: ClosedRange<UInt8> = TransportConfig.broadcastOriginTTLRange,
|
||||
randomTTL: (ClosedRange<UInt8>) -> UInt8 = { UInt8.random(in: $0) }
|
||||
) -> UInt8 {
|
||||
if let currentBurstTTL,
|
||||
let lastFrameAt,
|
||||
now.timeIntervalSince(lastFrameAt) < burstGap {
|
||||
return currentBurstTTL
|
||||
}
|
||||
return originTTL(range: range, randomTTL: randomTTL)
|
||||
}
|
||||
}
|
||||
@@ -227,12 +227,6 @@ final class BLEService: NSObject {
|
||||
private let bleMaxMTU = 512
|
||||
private let maxMessageLength = InputValidator.Limits.maxMessageLength
|
||||
private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
|
||||
/// Live-voice burst TTL state: one draw per talk burst rather than per
|
||||
/// frame, so a ~15 fps stream is a single sample to an observer instead of
|
||||
/// handing over the range maximum immediately. Both are touched only on
|
||||
/// `messageQueue`.
|
||||
private var currentVoiceBurstTTL: UInt8?
|
||||
private var lastVoiceBurstFrameAt: Date?
|
||||
// Flood/battery controls
|
||||
private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
|
||||
private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
|
||||
@@ -875,9 +869,7 @@ final class BLEService: NSObject {
|
||||
timestamp: sendTimestampMs,
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
// Not the fixed maximum: that would mark every message this device
|
||||
// wrote as originating here, to any direct listener.
|
||||
ttl: BLEOriginTTLPolicy.originTTL()
|
||||
ttl: messageTTL
|
||||
)
|
||||
guard let signedPacket = noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.error("❌ Failed to sign public message", category: .security)
|
||||
@@ -988,7 +980,7 @@ final class BLEService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: BLEOriginTTLPolicy.originTTL()
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
if let signed = noiseService.signPacket(leavePacket) {
|
||||
@@ -1625,7 +1617,7 @@ final class BLEService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: BLEOriginTTLPolicy.originTTL(),
|
||||
ttl: self.messageTTL,
|
||||
version: 2
|
||||
)
|
||||
|
||||
@@ -2900,12 +2892,7 @@ final class BLEService: NSObject {
|
||||
|
||||
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
|
||||
(
|
||||
// Publishing the neighbour list hands the local adjacency graph
|
||||
// to a single passive receiver; see
|
||||
// TransportConfig.announceIncludesDirectNeighbors.
|
||||
TransportConfig.announceIncludesDirectNeighbors
|
||||
? peerRegistry.connectedRoutingData
|
||||
: [],
|
||||
peerRegistry.connectedRoutingData,
|
||||
PeerCapabilities.localSupported.union(runtimeCapabilities),
|
||||
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
|
||||
)
|
||||
@@ -2984,7 +2971,7 @@ final class BLEService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: envelope,
|
||||
signature: nil,
|
||||
ttl: BLEOriginTTLPolicy.originTTL()
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
// Pre-mark our own broadcast as processed to avoid handling a
|
||||
// relayed self copy.
|
||||
@@ -3040,16 +3027,6 @@ final class BLEService: NSObject {
|
||||
guard !burstContent.isEmpty else { return }
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
// One TTL draw per talk burst. Drawing per frame would leak the
|
||||
// maximum within a fraction of a second at ~15 frames/sec.
|
||||
let now = Date()
|
||||
let burstTTL = BLEOriginTTLPolicy.voiceBurstTTL(
|
||||
now: now,
|
||||
lastFrameAt: self.lastVoiceBurstFrameAt,
|
||||
currentBurstTTL: self.currentVoiceBurstTTL
|
||||
)
|
||||
self.currentVoiceBurstTTL = burstTTL
|
||||
self.lastVoiceBurstFrameAt = now
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.voiceFrame.rawValue,
|
||||
senderID: self.myPeerIDData,
|
||||
@@ -3057,7 +3034,7 @@ final class BLEService: NSObject {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: burstContent,
|
||||
signature: nil,
|
||||
ttl: burstTTL
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
guard let signedPacket = self.noiseService.signPacket(packet) else {
|
||||
SecureLogger.error("❌ Failed to sign voice frame", category: .security)
|
||||
@@ -7632,7 +7609,7 @@ extension BLEService {
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: BLEOriginTTLPolicy.originTTL()
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.error("❌ Failed to sign board packet", category: .security)
|
||||
|
||||
@@ -6,73 +6,6 @@ enum TransportConfig {
|
||||
// BLE / Protocol
|
||||
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
|
||||
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
|
||||
|
||||
/// TTL range a public broadcast is originated with.
|
||||
///
|
||||
/// Originating every message at the maximum makes `ttl == messageTTLDefault`
|
||||
/// a reliable "this device wrote it" marker for any direct listener, which
|
||||
/// tells a passive observer who *said* a thing rather than merely who is
|
||||
/// present. Drawing from a range makes the lower values ambiguous between an
|
||||
/// origin and a relay: in a dense graph relays clamp broadcasts to 5, so an
|
||||
/// origin emitting 5 is indistinguishable from relayed traffic, and in a
|
||||
/// sparse chain a 6 could be an origin or one hop from a 7.
|
||||
///
|
||||
/// **What this does not do, stated plainly.** Every relay branch in
|
||||
/// `RelayController` emits `ttlLimit - 1`, so TTL is strictly decreasing and
|
||||
/// the top of whatever range is chosen can only ever come from an origin.
|
||||
/// With three values that is one message in three, and the probability that
|
||||
/// a sender has revealed itself after `k` messages is `1 - (2/3)^k` — about
|
||||
/// 87% by the fifth. So this meaningfully protects an occasional sender and
|
||||
/// barely protects a chatty one.
|
||||
///
|
||||
/// Removing the marker entirely is not possible from the origin side: it
|
||||
/// needs relays to sometimes *not* decrement, which trades directly against
|
||||
/// TTL's job as the loop bound. Widening the range trades against reach.
|
||||
/// Both belong in a follow-up with the mesh behaviour in scope; what this
|
||||
/// constant buys is that a single observed packet is no longer conclusive.
|
||||
///
|
||||
/// The cost is reach: a message originated at 5 crosses two fewer hops than
|
||||
/// one at 7. The upper bound stays at the default so the common case is
|
||||
/// unchanged, and the floor is deliberately not lower than the dense-graph
|
||||
/// relay clamp — going below it would cost reach without buying ambiguity
|
||||
/// that clamp does not already provide.
|
||||
///
|
||||
/// Applied to public broadcasts that carry content this device authored:
|
||||
/// public messages, group messages, broadcast files, board posts, live
|
||||
/// voice (one draw per talk burst, see `BLEOriginTTLPolicy`), and leave.
|
||||
/// Deliberately excluded:
|
||||
///
|
||||
/// - **Announces.** Link binding treats `ttl == messageTTLDefault` on an
|
||||
/// announce as "direct link", and an announce's sender ID already
|
||||
/// identifies the device — nothing to hide, something to break.
|
||||
/// - **Directed traffic** (DMs, handshakes, courier envelopes, directed
|
||||
/// files). Fewer hops means fewer deliveries, and the trade needs its own
|
||||
/// look rather than riding along here.
|
||||
/// - **Prekey bundles and gateway carriers.** A bundle already contains its
|
||||
/// owner's key, and carriers are re-broadcasts rather than authorship.
|
||||
static let broadcastOriginTTLRange: ClosedRange<UInt8> = 5...7
|
||||
|
||||
/// Whether signed announces advertise this device's direct neighbours.
|
||||
///
|
||||
/// The neighbour TLV carries up to ten 8-byte peer IDs, so a *single*
|
||||
/// passive receiver can reconstruct the local adjacency graph — who is
|
||||
/// standing next to whom — without needing several receivers or RSSI
|
||||
/// trilateration. For a crowd, that is the most sensitive thing the radio
|
||||
/// layer discloses, and unlike the identity keys it is not needed for the
|
||||
/// protocol to work.
|
||||
///
|
||||
/// Turning it off costs source routing. `MeshTopologyTracker` builds its
|
||||
/// adjacency map from these lists, and `computeRoute` needs that map, so
|
||||
/// with every device silent there are no routes to compute and directed
|
||||
/// traffic falls back to flooding — which is the documented fallback and is
|
||||
/// already what happens whenever a route fails. Expect more airtime for
|
||||
/// directed sends in dense meshes, and no correctness change.
|
||||
///
|
||||
/// Kept as a constant rather than a user setting because it is a protocol
|
||||
/// trade-off, not a preference: flipping it back is a one-line change, and
|
||||
/// receiving peers' lists is unaffected either way, so a mixed network
|
||||
/// behaves sensibly during any transition.
|
||||
static let announceIncludesDirectNeighbors = false
|
||||
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
|
||||
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
|
||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||
|
||||
@@ -39,7 +39,7 @@ struct BLEServiceCoreTests {
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!receivedDuplicate)
|
||||
|
||||
@@ -117,7 +117,7 @@ struct BLEServiceCoreTests {
|
||||
|
||||
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!unsignedRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
@@ -133,7 +133,7 @@ struct BLEServiceCoreTests {
|
||||
|
||||
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!badSignatureRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
@@ -1209,7 +1209,7 @@ struct BLEServiceCoreTests {
|
||||
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
gate.release()
|
||||
continuation.resume(returning: didObserveClosure)
|
||||
@@ -1340,7 +1340,7 @@ struct BLEServiceCoreTests {
|
||||
// rotated sender IDs never bought a sixth response.
|
||||
let exceededBudget = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .pong) > budget },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!exceededBudget)
|
||||
#expect(outbound.count(ofType: .pong) == budget)
|
||||
|
||||
@@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests {
|
||||
transport.simulateConnect(peerID, nickname: "alice")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action: User types /msg command
|
||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
|
||||
// Assert:
|
||||
@@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests {
|
||||
transport.simulateConnect(peerID, nickname: "troll")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action
|
||||
@@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests {
|
||||
// Assert
|
||||
// Verify identity manager was called to block "fingerprint_123"
|
||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didBlock)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests {
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
@@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests {
|
||||
{
|
||||
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
||||
},
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -321,7 +321,7 @@ struct ChatViewModelCommandTests {
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
let resolved = await TestHelpers.waitUntil({
|
||||
viewModel.getPeerIDForNickname("Alice") == peerID
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.negativeWaitWindow)
|
||||
#expect(resolved)
|
||||
|
||||
viewModel.handleCommand("/msg Alice")
|
||||
@@ -422,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
transport.sentReadReceipts.contains {
|
||||
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
|
||||
}
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.negativeWaitWindow)
|
||||
|
||||
#expect(sentReadReceipt)
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
||||
@@ -506,7 +506,7 @@ struct ChatViewModelReceivingTests {
|
||||
|
||||
let found = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(found)
|
||||
}
|
||||
@@ -535,11 +535,11 @@ struct ChatViewModelNoisePayloadTests {
|
||||
|
||||
let stored = await TestHelpers.waitUntil({
|
||||
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let acked = await TestHelpers.waitUntil({
|
||||
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(stored)
|
||||
#expect(acked)
|
||||
@@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return name == "Bob"
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(delivered)
|
||||
}
|
||||
@@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
||||
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
@@ -626,7 +626,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(privateChatUpdated)
|
||||
#expect(conversationStoreUpdated)
|
||||
@@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests {
|
||||
|
||||
let bound = await TestHelpers.waitUntil({
|
||||
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(bound)
|
||||
|
||||
let qr = VerificationService.VerificationQR(
|
||||
@@ -982,7 +982,7 @@ struct ChatViewModelPeerTests {
|
||||
|
||||
let cleaned = await TestHelpers.waitUntil({
|
||||
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(cleaned)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -151,7 +151,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -161,7 +161,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -169,7 +169,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(handedOver)
|
||||
// With CoreBluetooth disabled there is no physical link for the send
|
||||
@@ -183,7 +183,7 @@ struct CourierEndToEndTests {
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
@@ -229,7 +229,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -237,7 +237,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -245,7 +245,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -253,7 +253,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
@@ -265,7 +265,7 @@ struct CourierEndToEndTests {
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!delivered)
|
||||
}
|
||||
@@ -293,7 +293,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -301,7 +301,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -310,7 +310,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!leakedOnUnverifiedAnnounce)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -318,7 +318,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(announced)
|
||||
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -326,7 +326,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -355,7 +355,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -363,14 +363,14 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -385,7 +385,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let remoteHandover = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(remoteHandover)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -398,7 +398,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let freshAnnounce = try #require(
|
||||
@@ -410,7 +410,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let refloodedInCooldown = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!refloodedInCooldown)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -424,7 +424,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announcedAgain = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announcedAgain)
|
||||
let directAgain = try #require(
|
||||
@@ -434,7 +434,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!handedOverWithoutLinkProof)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -457,7 +457,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let queuedPacket = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!queuedPacket)
|
||||
}
|
||||
@@ -494,7 +494,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -532,7 +532,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -575,7 +575,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -602,14 +602,14 @@ struct CourierEndToEndTests {
|
||||
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(delivered)
|
||||
// Give a duplicate delivery a chance to surface, then confirm the
|
||||
// second copy never reached the delegate.
|
||||
let duplicated = await TestHelpers.waitUntil(
|
||||
{ bobDelegate.snapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!duplicated)
|
||||
#expect(bobDelegate.snapshot().count == 1)
|
||||
@@ -629,7 +629,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let initiated = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!initiated)
|
||||
|
||||
@@ -639,7 +639,7 @@ struct CourierEndToEndTests {
|
||||
ble.sendDeliveryAck(for: "msg-2", to: present)
|
||||
let initiatedForPresent = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(initiatedForPresent)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ struct PrekeyEndToEndTests {
|
||||
peer.sendBroadcastAnnounce()
|
||||
let published = await TestHelpers.waitUntil(
|
||||
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(published)
|
||||
return (
|
||||
@@ -124,7 +124,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
|
||||
@@ -138,7 +138,7 @@ struct PrekeyEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -149,7 +149,7 @@ struct PrekeyEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -158,7 +158,7 @@ struct PrekeyEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -166,7 +166,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
@@ -178,7 +178,7 @@ struct PrekeyEndToEndTests {
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
@@ -207,7 +207,7 @@ struct PrekeyEndToEndTests {
|
||||
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
|
||||
let redelivered = await TestHelpers.waitUntil(
|
||||
{ bobDelegate.snapshot().count == 2 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!redelivered)
|
||||
#expect(bobDelegate.snapshot().count == 1)
|
||||
@@ -235,7 +235,7 @@ struct PrekeyEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -248,7 +248,7 @@ struct PrekeyEndToEndTests {
|
||||
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(received)
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
@@ -272,7 +272,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
@@ -328,7 +328,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
// The verified bundle now participates in Alice's sync rounds.
|
||||
@@ -364,7 +364,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
@@ -396,7 +396,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
|
||||
@@ -37,7 +37,7 @@ struct GossipSyncManagerTests {
|
||||
}
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout)
|
||||
}
|
||||
|
||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||
@@ -394,7 +394,7 @@ struct GossipSyncManagerTests {
|
||||
)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout)
|
||||
// Barrier: flush the sync queue so a late third packet would be visible.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
let sentPackets = delegate.packets
|
||||
@@ -477,7 +477,7 @@ struct GossipSyncManagerTests {
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout)
|
||||
// Barrier: both requests have been processed once this returns.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
#expect(delegate.packets.count == 1)
|
||||
@@ -498,7 +498,7 @@ struct GossipSyncManagerTests {
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let packet = try #require(delegate.packets.first)
|
||||
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
|
||||
let types = try #require(request.types)
|
||||
@@ -553,7 +553,7 @@ struct GossipSyncManagerTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
@@ -615,7 +615,7 @@ struct GossipSyncManagerTests {
|
||||
)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
// Barrier: flush the sync queue so a late second packet would be visible.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
let sentPackets = delegate.packets
|
||||
@@ -641,7 +641,7 @@ struct GossipSyncManagerTests {
|
||||
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
||||
manager.requestMissingFragments(fragmentIDs: [stalledID])
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.requestSync.rawValue)
|
||||
#expect(sent.ttl == 0)
|
||||
@@ -697,7 +697,7 @@ struct GossipSyncManagerTests {
|
||||
// And a .prekeyBundle sync request is answered with the stored packet.
|
||||
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let served = try #require(delegate.packets.first)
|
||||
#expect(served.type == MessageType.prekeyBundle.rawValue)
|
||||
#expect(served.isRSR)
|
||||
@@ -774,7 +774,7 @@ struct GossipSyncManagerTests {
|
||||
)
|
||||
let restored = await TestHelpers.waitUntil(
|
||||
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(restored)
|
||||
}
|
||||
@@ -844,7 +844,7 @@ struct GossipSyncManagerTests {
|
||||
!FileManager.default.fileExists(atPath: fileURL.path)
|
||||
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
|
||||
},
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(erased)
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -723,9 +723,9 @@ struct NoiseCoverageTests {
|
||||
// A failed startup requirement must not strand a late thread in
|
||||
// the blocking test double after the test has returned.
|
||||
oldSession.resumeDecrypt()
|
||||
_ = decryptResult.wait(timeout: 5)
|
||||
_ = decryptResult.wait(timeout: TestConstants.settleTimeout)
|
||||
if let promotionResultForCleanup {
|
||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||
_ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,15 +751,19 @@ struct NoiseCoverageTests {
|
||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||
promotionThread.qualityOfService = .userInitiated
|
||||
promotionThread.start()
|
||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||
try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success)
|
||||
#expect(
|
||||
// test-timing-ok: a NEGATIVE wait — it asserts the promotion has
|
||||
// NOT completed yet, so a long deadline would only make the suite
|
||||
// slow while still passing. A starved runner can only make this
|
||||
// more likely to hold, never less.
|
||||
promotionResult.wait(timeout: 0.05) == nil,
|
||||
"Promotion must wait for the exact decrypting-session lease"
|
||||
)
|
||||
|
||||
oldSession.resumeDecrypt()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
|
||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||
|
||||
@@ -580,8 +580,16 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
/// constrained CI runners (2-core, serialized testing) can starve the
|
||||
/// detached utility-priority fetch task for seconds before it runs, and
|
||||
/// a successful wait returns as soon as the condition becomes true.
|
||||
/// Default deliberately far larger than the work being awaited.
|
||||
///
|
||||
/// The directory performs its fetch in a `Task.detached(priority: .utility)`,
|
||||
/// and utility priority competes with every other suite on a CI runner. At
|
||||
/// ten seconds the retry-scheduling test timed out at exactly 10.06s with
|
||||
/// the retry never scheduled — which reads like a missing retry rather than
|
||||
/// a starved background task. Returning as soon as the condition holds means
|
||||
/// a longer deadline only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 10.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () async -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -188,7 +188,7 @@ struct PTTBurstPlayerTests {
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
wait(for: [expectation], timeout: TestConstants.settleTimeout)
|
||||
XCTAssertTrue(service.isFavorite(peerKey))
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
||||
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
||||
|
||||
@@ -227,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -63,7 +63,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
context.service.start()
|
||||
context.service.setUserTorEnabled(false)
|
||||
|
||||
wait(for: [notified], timeout: 1.0)
|
||||
wait(for: [notified], timeout: TestConstants.negativeWaitWindow)
|
||||
context.notificationCenter.removeObserver(token)
|
||||
|
||||
XCTAssertFalse(context.service.userTorEnabled)
|
||||
@@ -243,7 +243,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -69,25 +69,45 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
||||
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
||||
}
|
||||
|
||||
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
|
||||
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
|
||||
/// Wiring only: a duplicate mid-window still yields exactly one committed
|
||||
/// `false`, published through the monitor's debounce.
|
||||
///
|
||||
/// This deliberately makes no assertion about *when* the flush fires. It
|
||||
/// used to bound elapsed wall-clock time at 1.4 s to prove the deadline was
|
||||
/// not restarted, which flaked on loaded CI runners — one observed run took
|
||||
/// 3.75 s, because `Task.sleep` and the `asyncAfter` flush are both real
|
||||
/// time and neither is bounded above on a busy machine. No wall-clock bound
|
||||
/// can distinguish "deadline preserved" from "runner is slow", so the timing
|
||||
/// property is asserted where it is computable instead:
|
||||
/// `test_debounce_duplicateObservationsPreservePendingDeadline` drives
|
||||
/// `ReachabilityDebounce` with injected timestamps and checks
|
||||
/// `pendingRemaining` directly.
|
||||
///
|
||||
/// The clock is injected here so the debounce arithmetic is deterministic
|
||||
/// even though the flush itself is scheduled in real time.
|
||||
func test_monitor_duplicateUpdatesCommitOnceThroughTheDebounce() async {
|
||||
let clock = MutableDate(now: Date(timeIntervalSince1970: 1_784_000_000))
|
||||
let monitor = NWPathReachabilityMonitor(
|
||||
debounceInterval: 0.2,
|
||||
now: { clock.now }
|
||||
)
|
||||
var received: [Bool] = []
|
||||
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
let start = Date()
|
||||
monitor.ingest(reachable: false)
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
// Duplicate unsatisfied update mid-window (e.g. interface detail change
|
||||
// while still offline) must not restart the debounce window.
|
||||
// Duplicate unsatisfied update mid-window (e.g. an interface detail
|
||||
// change while still offline).
|
||||
clock.now = clock.now.addingTimeInterval(0.1)
|
||||
monitor.ingest(reachable: false)
|
||||
// Past the original deadline, so the scheduled flush commits.
|
||||
clock.now = clock.now.addingTimeInterval(0.2)
|
||||
|
||||
let committed = await waitUntil(timeout: 2.0) { !received.isEmpty }
|
||||
// Generous: this is a liveness check, not a latency bound. A real
|
||||
// regression — never committing — still fails, just later.
|
||||
let committed = await waitUntil(timeout: 10.0) { !received.isEmpty }
|
||||
XCTAssertTrue(committed)
|
||||
XCTAssertEqual(received, [false])
|
||||
// The flush must fire at the original ~1.0s deadline, not ~1.5s
|
||||
// (a full interval after the duplicate).
|
||||
XCTAssertLessThan(Date().timeIntervalSince(start), 1.4)
|
||||
}
|
||||
|
||||
// MARK: - Service gating
|
||||
@@ -179,7 +199,7 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
@@ -239,3 +259,13 @@ private final class GateMockProxyController: NetworkActivationProxyControlling {
|
||||
private(set) var proxyModes: [Bool] = []
|
||||
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
|
||||
}
|
||||
|
||||
/// Controllable clock, so debounce arithmetic is deterministic even where the
|
||||
/// flush itself is scheduled in real time.
|
||||
private final class MutableDate: @unchecked Sendable {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(authenticated)
|
||||
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||
{ recorder.generationCount >= 1 },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(generationAuthenticated)
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
@@ -166,7 +166,7 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!emittedAuthentication)
|
||||
}
|
||||
@@ -216,7 +216,7 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
@@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests {
|
||||
)
|
||||
let retried = await TestHelpers.waitUntil(
|
||||
{ recorder.messages.count == 1 },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(retried)
|
||||
let retryExpired = await TestHelpers.waitUntil(
|
||||
{ !service.hasSession(with: peerID) },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(retryExpired)
|
||||
#expect(recorder.timeoutCount == 1)
|
||||
@@ -710,7 +710,12 @@ struct NoiseEncryptionServiceTests {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
ordinaryResponderHandshakeTimeout: 0.06,
|
||||
// Generous for the same reason as the quarantine-restore test
|
||||
// (#1483): this timeout also arms during the `establishSessions`
|
||||
// setup handshake below, where bob is the responder. At 0.06 a
|
||||
// preempted runner could fire it mid-setup, tear down the half-open
|
||||
// responder, and make message 3 be answered as a fresh initiation.
|
||||
ordinaryResponderHandshakeTimeout: 1.0,
|
||||
ordinaryReconnectRollbackCooldown: 0.3
|
||||
)
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
@@ -741,12 +746,12 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
let restored = await TestHelpers.waitUntil(
|
||||
{ bob.hasEstablishedSession(with: alicePeerID) },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(restored)
|
||||
let callbackArrived = await TestHelpers.waitUntil(
|
||||
{ recovery.timeoutCount == 1 },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(callbackArrived)
|
||||
|
||||
@@ -780,7 +785,13 @@ struct NoiseEncryptionServiceTests {
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
ordinaryHandshakeTimeout: 0.04,
|
||||
ordinaryResponderHandshakeTimeout: 0.04
|
||||
// Also arms during the `establishSessions` setup handshake below,
|
||||
// where bob is the responder. Observed failing on a loaded CI
|
||||
// runner with exactly the signature #1483 documented: the setup's
|
||||
// `#expect(finalMessage == nil)` saw a 96-byte message 2, because
|
||||
// the half-open responder had already been torn down and message 3
|
||||
// was answered as a fresh initiation.
|
||||
ordinaryResponderHandshakeTimeout: 1.0
|
||||
)
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
@@ -814,12 +825,12 @@ struct NoiseEncryptionServiceTests {
|
||||
// initiates one bounded convergence retry; drop that message 1 too.
|
||||
let retryPrepared = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(retryPrepared)
|
||||
let retryExpired = await TestHelpers.waitUntil(
|
||||
{ !bob.hasSession(with: alicePeerID) },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(retryExpired)
|
||||
#expect(recovery.timeoutCount == 1)
|
||||
@@ -1026,7 +1037,7 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
let requested = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: 1
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(requested)
|
||||
let retryMessage1 = try #require(recovery.messages.first)
|
||||
|
||||
@@ -960,7 +960,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
|
||||
}
|
||||
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) {
|
||||
receivedIDs.count == events.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
@@ -1006,7 +1006,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
|
||||
|
||||
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
|
||||
let quietDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { quietDeliveredAfterBusyCount >= 0 }
|
||||
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
|
||||
|
||||
// The signal: B did not have to wait for A's entire backlog. If the two
|
||||
@@ -1019,7 +1019,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
)
|
||||
|
||||
// Both relays still drain fully and in order.
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) {
|
||||
busyDeliveredCount == busyEvents.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
@@ -1907,7 +1907,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -164,7 +164,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -209,7 +209,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -250,7 +250,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
|
||||
@@ -288,7 +288,7 @@ struct NostrTransportTests {
|
||||
messageID: "geo-1"
|
||||
)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Two radio-layer metadata leaks that need no cross-platform agreement to
|
||||
/// close: the neighbour list in announces, and the fixed origin TTL.
|
||||
struct RadioMetadataTests {
|
||||
|
||||
// MARK: - Origin TTL
|
||||
|
||||
@Test func originTTLStaysInsideTheConfiguredRange() {
|
||||
let range = TransportConfig.broadcastOriginTTLRange
|
||||
for _ in 0..<200 {
|
||||
let ttl = BLEOriginTTLPolicy.originTTL()
|
||||
#expect(range.contains(ttl))
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of the change: a message must not always leave at the maximum,
|
||||
/// because a direct listener reads `ttl == max` as "this device wrote it".
|
||||
@Test func originTTLDoesNotAlwaysUseTheMaximum() {
|
||||
var seen = Set<UInt8>()
|
||||
for _ in 0..<500 {
|
||||
seen.insert(BLEOriginTTLPolicy.originTTL())
|
||||
}
|
||||
#expect(seen.count > 1)
|
||||
#expect(seen.contains { $0 < TransportConfig.messageTTLDefault })
|
||||
}
|
||||
|
||||
@Test func originTTLUsesTheInjectedRandomizer() {
|
||||
let ttl = BLEOriginTTLPolicy.originTTL(range: 5...7, randomTTL: { _ in 6 })
|
||||
#expect(ttl == 6)
|
||||
}
|
||||
|
||||
/// A send path must never trap on a bad range.
|
||||
@Test func degenerateRangesFallBackToTheDefault() {
|
||||
#expect(BLEOriginTTLPolicy.originTTL(range: 0...0) == TransportConfig.messageTTLDefault)
|
||||
// Single-value range is legitimate and must be honoured.
|
||||
#expect(BLEOriginTTLPolicy.originTTL(range: 4...4) == 4)
|
||||
}
|
||||
|
||||
/// The floor must not drop below the dense-graph relay clamp: going lower
|
||||
/// costs reach without buying ambiguity the clamp does not already provide.
|
||||
@Test func rangeSitsBetweenTheDenseClampAndTheDefault() {
|
||||
let range = TransportConfig.broadcastOriginTTLRange
|
||||
#expect(range.upperBound == TransportConfig.messageTTLDefault)
|
||||
#expect(range.lowerBound >= TransportConfig.bleFragmentRelayTtlCapDense)
|
||||
#expect(range.lowerBound >= 2, "TTL 1 is dropped by RelayController")
|
||||
}
|
||||
|
||||
// MARK: - Voice burst TTL
|
||||
|
||||
/// Per-frame drawing would be worse than useless: at ~15 frames a second an
|
||||
/// observer collects the range maximum almost immediately, so the sender is
|
||||
/// marked within a fraction of a second while every low draw still costs
|
||||
/// reach. One draw per burst makes a burst a single sample.
|
||||
@Test func voiceFramesInOneBurstShareOneTTL() {
|
||||
let start = Date(timeIntervalSince1970: 1_784_000_000)
|
||||
let first = BLEOriginTTLPolicy.voiceBurstTTL(
|
||||
now: start, lastFrameAt: nil, currentBurstTTL: nil, randomTTL: { _ in 6 }
|
||||
)
|
||||
// Subsequent frames inside the burst must reuse it even though the
|
||||
// randomizer would now return something else.
|
||||
var last = start
|
||||
var current = first
|
||||
for step in 1...20 {
|
||||
let now = start.addingTimeInterval(Double(step) * 0.066)
|
||||
current = BLEOriginTTLPolicy.voiceBurstTTL(
|
||||
now: now, lastFrameAt: last, currentBurstTTL: current, randomTTL: { _ in 7 }
|
||||
)
|
||||
last = now
|
||||
#expect(current == first)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func aNewBurstRedrawsTheTTL() {
|
||||
let start = Date(timeIntervalSince1970: 1_784_000_000)
|
||||
let first = BLEOriginTTLPolicy.voiceBurstTTL(
|
||||
now: start, lastFrameAt: nil, currentBurstTTL: nil, randomTTL: { _ in 5 }
|
||||
)
|
||||
#expect(first == 5)
|
||||
|
||||
// A gap longer than the burst window means a new talk burst.
|
||||
let later = start.addingTimeInterval(BLEOriginTTLPolicy.voiceBurstGap + 0.5)
|
||||
let second = BLEOriginTTLPolicy.voiceBurstTTL(
|
||||
now: later, lastFrameAt: start, currentBurstTTL: first, randomTTL: { _ in 7 }
|
||||
)
|
||||
#expect(second == 7)
|
||||
}
|
||||
|
||||
@Test func voiceBurstTTLStaysInRange() {
|
||||
var last: Date?
|
||||
var current: UInt8?
|
||||
let start = Date(timeIntervalSince1970: 1_784_000_000)
|
||||
for step in 0..<200 {
|
||||
// Gaps long enough to force a fresh draw each time.
|
||||
let now = start.addingTimeInterval(Double(step) * 5)
|
||||
let ttl = BLEOriginTTLPolicy.voiceBurstTTL(
|
||||
now: now, lastFrameAt: last, currentBurstTTL: current
|
||||
)
|
||||
#expect(TransportConfig.broadcastOriginTTLRange.contains(ttl))
|
||||
last = now
|
||||
current = ttl
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wiring
|
||||
|
||||
/// The first version of this change defined the policy and used it in
|
||||
/// exactly one place, leaving voice, files, group messages and board posts
|
||||
/// originating at a fixed maximum — i.e. still perfectly marked, while the
|
||||
/// docs claimed otherwise. A policy that exists but is not wired is worse
|
||||
/// than none, because it reads as solved.
|
||||
///
|
||||
/// This asserts against the source rather than behaviour because the send
|
||||
/// paths need a live radio; it is a cheap guard against the specific
|
||||
/// regression of adding a broadcast origination site and forgetting it.
|
||||
@Test func everyAuthoredBroadcastOriginatesWithADrawnTTL() throws {
|
||||
let source = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // Services
|
||||
.deletingLastPathComponent() // bitchatTests
|
||||
.deletingLastPathComponent() // repo root
|
||||
.appendingPathComponent("bitchat/Services/BLE/BLEService.swift")
|
||||
let lines = try String(contentsOf: source, encoding: .utf8)
|
||||
.components(separatedBy: .newlines)
|
||||
|
||||
// Packet constructions that still pin the fixed maximum.
|
||||
let fixed = lines.enumerated().filter {
|
||||
$0.element.contains("ttl: messageTTL") || $0.element.contains("ttl: self.messageTTL")
|
||||
}
|
||||
|
||||
// Each remaining one must be a deliberate exclusion. Announces keep the
|
||||
// fixed TTL for link binding; the rest are directed, diagnostics, or
|
||||
// re-broadcasts. Identify by the packet type named just above.
|
||||
let allowedTypes = [
|
||||
"announce", // link binding depends on ttl == max
|
||||
"ping", "pong", // diagnostics; payload records origin TTL for hops
|
||||
"noiseEncrypted", "noiseHandshake", "courierEnvelope", // directed
|
||||
"fileTransfer", // the directed variant; the broadcast one is drawn
|
||||
"prekeyBundle", // payload already names its owner
|
||||
"nostrCarrier" // re-broadcast, not authorship
|
||||
]
|
||||
|
||||
var unexplained: [String] = []
|
||||
for (index, line) in fixed {
|
||||
let window = lines[max(0, index - 14)...index].joined(separator: "\n")
|
||||
guard !allowedTypes.contains(where: { window.contains("MessageType.\($0)") }) else { continue }
|
||||
unexplained.append("BLEService.swift:\(index + 1) — \(line.trimmingCharacters(in: .whitespaces))")
|
||||
}
|
||||
|
||||
#expect(
|
||||
unexplained.isEmpty,
|
||||
"""
|
||||
These broadcast origination sites still use the fixed maximum TTL, \
|
||||
which marks this device as the author to any direct listener. Use \
|
||||
BLEOriginTTLPolicy.originTTL(), or add the type to allowedTypes here \
|
||||
with the reason.
|
||||
|
||||
\(unexplained.joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Neighbour list
|
||||
|
||||
@Test func neighborAdvertisingIsOffByDefault() {
|
||||
#expect(!TransportConfig.announceIncludesDirectNeighbors)
|
||||
}
|
||||
|
||||
/// The mechanism that makes this backward compatible: an empty list omits
|
||||
/// the TLV entirely rather than emitting a zero-length one, and the decoder
|
||||
/// treats its absence as "no topology offered".
|
||||
@Test func emptyNeighborListOmitsTheTLV() throws {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: [],
|
||||
capabilities: [.bridge]
|
||||
)
|
||||
let encoded = try #require(announcement.encode())
|
||||
|
||||
// TLV type 0x04 is the neighbour list; it must not appear at all.
|
||||
var offset = encoded.startIndex
|
||||
var types: [UInt8] = []
|
||||
while offset < encoded.endIndex {
|
||||
guard encoded.distance(from: offset, to: encoded.endIndex) >= 2 else { break }
|
||||
let type = encoded[offset]
|
||||
let length = Int(encoded[encoded.index(after: offset)])
|
||||
types.append(type)
|
||||
offset = encoded.index(offset, offsetBy: 2 + length)
|
||||
}
|
||||
#expect(!types.contains(0x04))
|
||||
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.directNeighbors == nil)
|
||||
// Everything else still round-trips, so old peers lose nothing but the
|
||||
// topology hint.
|
||||
#expect(decoded.nickname == "alice")
|
||||
#expect(decoded.capabilities == [.bridge])
|
||||
}
|
||||
|
||||
/// Receiving a neighbour list must keep working: peers on older builds still
|
||||
/// send one, and a mixed mesh has to behave sensibly.
|
||||
@Test func receivedNeighborListsAreStillParsed() throws {
|
||||
let neighbors = [Data(repeating: 0xA1, count: 8), Data(repeating: 0xB2, count: 8)]
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "bob",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: neighbors
|
||||
)
|
||||
let encoded = try #require(announcement.encode())
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.directNeighbors == neighbors)
|
||||
}
|
||||
}
|
||||
@@ -562,7 +562,7 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -320,7 +320,7 @@ struct SecureIdentityStateManagerVouchTests {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct GossipSyncBoardTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
@@ -69,7 +69,7 @@ struct GossipSyncBoardTests {
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ final class RequestSyncManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -11,7 +11,6 @@ import Foundation
|
||||
|
||||
struct TestConstants {
|
||||
static let defaultTimeout: TimeInterval = 5.0
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
/// For positive waits on work that hops through `Task.detached` or
|
||||
/// background queues: those contend with every parallel test worker for
|
||||
/// the global executor, so a loaded CI runner can exceed
|
||||
@@ -19,6 +18,47 @@ struct TestConstants {
|
||||
/// so passing runs never pay the longer timeout.
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
/// **Default deadline for any "wait until this async thing settles" helper.**
|
||||
///
|
||||
/// Four separate tests flaked on CI during July 2026 with the same root
|
||||
/// cause, and it is worth stating the rule rather than re-learning it a
|
||||
/// fifth time: *a wait deadline is not a latency budget.* It exists so a
|
||||
/// genuine hang eventually fails the suite. Size it for the worst-case
|
||||
/// scheduler, never for how long the operation "should" take.
|
||||
///
|
||||
/// A CI runner executes many suites at once. Work behind `@MainActor`,
|
||||
/// `Task.detached(priority: .utility)`, or a `DispatchQueue.asyncAfter` can
|
||||
/// be starved for seconds — one observed run took 3.75 s for a 1 s
|
||||
/// operation. Deadlines sized to the operation (the old 1 s defaults) turn
|
||||
/// that starvation into a red build that reads like a product bug.
|
||||
///
|
||||
/// This costs nothing when tests pass, because every helper returns as soon
|
||||
/// as its condition holds. It only extends the genuine-failure case.
|
||||
///
|
||||
/// `TestTimingHygieneTests` enforces that wait helpers default to at least
|
||||
/// `minimumSettleTimeout`.
|
||||
static let settleTimeout: TimeInterval = 30.0
|
||||
|
||||
/// Floor enforced by `TestTimingHygieneTests`. Anything below this is a
|
||||
/// latency assumption in disguise.
|
||||
static let minimumSettleTimeout: TimeInterval = 10.0
|
||||
|
||||
/// For waits whose **expected outcome is `false`** — "prove this does not
|
||||
/// happen".
|
||||
///
|
||||
/// The floor above is wrong for these, and inverted: a negative wait always
|
||||
/// runs its deadline out, so `settleTimeout` would spend 30 s per case
|
||||
/// proving nothing extra. Starvation cannot cause a false failure here
|
||||
/// either — a starved runner only makes the thing *less* likely to happen,
|
||||
/// so the assertion still holds. Short is correct, and naming it says the
|
||||
/// polarity out loud instead of leaving a bare literal that reads like the
|
||||
/// mistake this file exists to prevent.
|
||||
///
|
||||
/// `TestTimingHygieneTests` accepts this by name. Using it for a wait you
|
||||
/// expect to succeed reintroduces exactly the flake class it sits next to.
|
||||
static let negativeWaitWindow: TimeInterval = 1.0
|
||||
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
static let testNickname2 = "Bob"
|
||||
static let testNickname3 = "Charlie"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Guards the test suite against the flake class that produced four separate
|
||||
/// red builds in July 2026: **treating a wait deadline as a latency budget.**
|
||||
///
|
||||
/// A CI runner executes many suites at once, so work behind `@MainActor`,
|
||||
/// `Task.detached(priority: .utility)`, or `DispatchQueue.asyncAfter` can be
|
||||
/// starved for seconds. One observed run took 3.75 s for a 1 s operation.
|
||||
/// Deadlines sized to how long the operation "should" take turn that starvation
|
||||
/// into a red build that reads like a product bug, and the debugging cost lands
|
||||
/// on whoever opened an unrelated PR.
|
||||
///
|
||||
/// Two rules, both enforced below:
|
||||
///
|
||||
/// 1. A wait helper's default deadline must be at least
|
||||
/// `TestConstants.minimumSettleTimeout`. Waits return as soon as their
|
||||
/// condition holds, so a generous deadline is free in the passing case.
|
||||
/// 2. No test asserts an *upper bound* on elapsed wall-clock time. Such an
|
||||
/// assertion cannot distinguish the behaviour under test from a slow
|
||||
/// machine, so it can only be flaky. Assert the property somewhere it is
|
||||
/// computable — with an injected clock, on the pure logic — instead.
|
||||
///
|
||||
/// Both rules can be waived per line with `\(Self.waiver)` plus a reason, for
|
||||
/// the rare case where the timing itself is genuinely the thing under test.
|
||||
struct TestTimingHygieneTests {
|
||||
/// Opt-out marker. Reviewers should expect a reason next to it.
|
||||
static let waiver = "test-timing-ok:"
|
||||
|
||||
private static let testsRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // TestUtilities
|
||||
.deletingLastPathComponent() // bitchatTests
|
||||
|
||||
private struct Line {
|
||||
let file: String
|
||||
let number: Int
|
||||
let text: String
|
||||
/// True when the waiver appears on this line or in the comment block
|
||||
/// immediately above it, so a reason can be written at readable length
|
||||
/// rather than crammed onto the end of the code line.
|
||||
let waived: Bool
|
||||
}
|
||||
|
||||
private static func swiftLines() throws -> [Line] {
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: testsRoot,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
var out: [Line] = []
|
||||
while let url = enumerator?.nextObject() as? URL {
|
||||
guard url.pathExtension == "swift" else { continue }
|
||||
// This file necessarily contains the patterns it bans.
|
||||
guard url.lastPathComponent != "TestTimingHygieneTests.swift" else { continue }
|
||||
let name = url.lastPathComponent
|
||||
let texts = try String(contentsOf: url, encoding: .utf8)
|
||||
.components(separatedBy: .newlines)
|
||||
for (index, text) in texts.enumerated() {
|
||||
// Scan back over an unbroken run of comment lines.
|
||||
var waived = text.contains(waiver)
|
||||
var back = index - 1
|
||||
while !waived, back >= 0 {
|
||||
let above = texts[back].trimmingCharacters(in: .whitespaces)
|
||||
guard above.hasPrefix("//") else { break }
|
||||
waived = above.contains(waiver)
|
||||
back -= 1
|
||||
}
|
||||
out.append(Line(file: name, number: index + 1, text: text, waived: waived))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func isWaived(_ line: Line) -> Bool {
|
||||
line.waived
|
||||
}
|
||||
|
||||
/// Rule 1: no wait helper may default to a deadline below the floor.
|
||||
@Test func waitHelpersDoNotDefaultToShortDeadlines() throws {
|
||||
let lines = try Self.swiftLines()
|
||||
#expect(!lines.isEmpty, "hygiene scan found no test sources — check the path")
|
||||
|
||||
// Two shapes, both of which have flaked here:
|
||||
// a declaration default — `timeout: TimeInterval = 2.5`
|
||||
// a wait call site — `wait(for:…, timeout: 1.0)`, `waitUntil(timeout: 5.0)`
|
||||
//
|
||||
// Deliberately NOT matched: a bare `timeout:` label on something that is
|
||||
// not a wait, such as the injected production handshake timeouts in the
|
||||
// Noise tests. Those are the behaviour under test, and a short value is
|
||||
// correct there.
|
||||
let patterns = [
|
||||
#"(?:timeout|deadline)\s*:\s*TimeInterval\s*=\s*([0-9]+(?:\.[0-9]+)?)"#,
|
||||
#"(?:wait|waitUntil|waitFor|fulfillment)\s*\([^)]*\btimeout:\s*([0-9]+(?:\.[0-9]+)?)"#
|
||||
].map { try? NSRegularExpression(pattern: $0) }.compactMap { $0 }
|
||||
#expect(patterns.count == 2, "hygiene regexes failed to compile")
|
||||
|
||||
// Named constants hide the same mistake behind a symbol, and did: the
|
||||
// fifth flake of the session was `timeout: TestConstants.shortTimeout`
|
||||
// (1 s) on a positive wait, which a literals-only scan cannot see.
|
||||
// `shortTimeout` itself is deleted (Periphery flagged it dead once its
|
||||
// last wait site converted); the ban stays so it cannot come back.
|
||||
// `negativeWaitWindow` is deliberately absent — short is correct there.
|
||||
let bannedConstants = ["shortTimeout", "defaultTimeout"]
|
||||
|
||||
var offenders: [String] = []
|
||||
for line in lines where !Self.isWaived(line) {
|
||||
let range = NSRange(line.text.startIndex..., in: line.text)
|
||||
var flagged = false
|
||||
for pattern in patterns {
|
||||
guard let match = pattern.firstMatch(in: line.text, range: range),
|
||||
let valueRange = Range(match.range(at: 1), in: line.text),
|
||||
let value = TimeInterval(line.text[valueRange]),
|
||||
value < TestConstants.minimumSettleTimeout else { continue }
|
||||
offenders.append("\(line.file):\(line.number) — \(value)s: \(line.text.trimmingCharacters(in: .whitespaces))")
|
||||
flagged = true
|
||||
break
|
||||
}
|
||||
guard !flagged else { continue }
|
||||
for name in bannedConstants
|
||||
where line.text.contains("timeout: TestConstants.\(name)") {
|
||||
offenders.append("\(line.file):\(line.number) — TestConstants.\(name): \(line.text.trimmingCharacters(in: .whitespaces))")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
#expect(
|
||||
offenders.isEmpty,
|
||||
"""
|
||||
Wait deadlines below \(TestConstants.minimumSettleTimeout)s are latency \
|
||||
assumptions and will flake on a loaded runner. Use \
|
||||
TestConstants.settleTimeout, or add "\(Self.waiver) <reason>" if the \
|
||||
timing really is what the test asserts.
|
||||
|
||||
\(offenders.joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// Rule 2: no test bounds elapsed wall-clock time from above.
|
||||
///
|
||||
/// This is the assertion that started it all — `XCTAssertLessThan(
|
||||
/// Date().timeIntervalSince(start), 1.4)` proving a debounce deadline was
|
||||
/// not restarted. It cannot separate "behaved correctly" from "runner was
|
||||
/// busy", so it only ever fails for the wrong reason.
|
||||
@Test func testsDoNotAssertUpperBoundsOnElapsedTime() throws {
|
||||
let lines = try Self.swiftLines()
|
||||
|
||||
let elapsedAssertion = try NSRegularExpression(
|
||||
pattern: #"(?:XCTAssertLessThan|XCTAssertLessThanOrEqual)\s*\(\s*(?:Date\(\)\.timeIntervalSince|[A-Za-z_][A-Za-z0-9_]*\.timeIntervalSince|ContinuousClock)"#
|
||||
)
|
||||
|
||||
var offenders: [String] = []
|
||||
for line in lines where !Self.isWaived(line) {
|
||||
let range = NSRange(line.text.startIndex..., in: line.text)
|
||||
guard elapsedAssertion.firstMatch(in: line.text, range: range) != nil else { continue }
|
||||
offenders.append("\(line.file):\(line.number) — \(line.text.trimmingCharacters(in: .whitespaces))")
|
||||
}
|
||||
|
||||
#expect(
|
||||
offenders.isEmpty,
|
||||
"""
|
||||
An upper bound on elapsed wall-clock time cannot distinguish the \
|
||||
behaviour under test from a slow machine. Assert the property where \
|
||||
it is computable — inject a clock, or test the pure logic — or add \
|
||||
"\(Self.waiver) <reason>".
|
||||
|
||||
\(offenders.joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// The floor must stay meaningfully above the operations being waited on,
|
||||
/// and the default must satisfy the rule this file enforces.
|
||||
@Test func settleTimeoutsAreSelfConsistent() {
|
||||
#expect(TestConstants.settleTimeout >= TestConstants.minimumSettleTimeout)
|
||||
#expect(TestConstants.minimumSettleTimeout > TestConstants.defaultTimeout)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ struct VoiceCaptureSessionTests {
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -59,11 +59,24 @@ struct VoiceNotePlaybackControllerTests {
|
||||
return url
|
||||
}
|
||||
|
||||
/// Waits for an async settle, then asserts.
|
||||
///
|
||||
/// The deadline is deliberately far larger than the work it waits on. Every
|
||||
/// condition here depends on a `@MainActor` Task that playback schedules
|
||||
/// (the session acquire and its failure path), and on a CI runner executing
|
||||
/// many suites in parallel that Task can simply not be scheduled for
|
||||
/// seconds. At five seconds this timed out on CI and reported *two*
|
||||
/// failures — the wait itself, and the `!isPlaying` that the un-run failure
|
||||
/// path had not yet reset — which reads like a playback bug rather than a
|
||||
/// starved scheduler.
|
||||
///
|
||||
/// A generous deadline costs nothing when the condition holds, since this
|
||||
/// returns as soon as it does; it only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -26,22 +26,13 @@ Signed announces can expose:
|
||||
|
||||
- Nickname, persistent Noise public key, and Ed25519 signing public key
|
||||
- Capability flags
|
||||
- A bounded set of short direct-neighbor identifiers
|
||||
- A coarse rendezvous geohash when the bridge capability is enabled
|
||||
|
||||
Announces no longer advertise this device's direct neighbours. That TLV carried up to ten peer IDs, so a single passive receiver could reconstruct the local adjacency graph — who is standing next to whom — with no need for multiple receivers or signal-strength trilateration. It is off by default (`TransportConfig.announceIncludesDirectNeighbors`). Neighbour lists from other peers are still parsed, so a mixed network behaves sensibly. The cost is source routing: its adjacency map comes from these lists, so directed traffic falls back to flooding, which is already the documented fallback whenever a route fails.
|
||||
|
||||
The app does not advertise the device's assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address.
|
||||
|
||||
That randomization does not deliver the unlinkability it might suggest, because the application layer publishes stable identifiers above it. The 8-byte peer ID in every packet header is the first 8 bytes of the Noise static key fingerprint, so it does not rotate; announces carry the static keys themselves; and the fixed service UUID makes any bitchat device detectable as such by a passive scanner. A receiver in radio range can therefore recognise a specific device across sessions and locations, and detect that the app is in use at all. RSSI, timing, traffic volume, and radio fingerprints remain observable as well.
|
||||
|
||||
Public broadcasts that carry authored content — public and group messages, broadcast files, board posts, live voice, and leave — are originated with a TTL drawn from a range rather than always at the maximum. A fixed maximum made `ttl == default` a reliable "this device wrote it" marker to any direct listener, disclosing authorship rather than mere presence. Live voice draws once per talk burst, not per frame; at roughly 15 frames a second a per-frame draw would surrender the range maximum almost immediately.
|
||||
|
||||
This reduces the marker, it does not remove it. Relays strictly decrement, so the top of the range can still only come from an origin — one message in three, or about an 87% chance of self-identifying within five messages. It protects an occasional sender considerably and a chatty one little. Eliminating it needs relays to sometimes not decrement, which trades against TTL's role as the loop bound, so it is deliberately left as follow-up rather than claimed here.
|
||||
|
||||
Announces keep the fixed TTL: link binding reads a maximum-TTL announce as a direct link, and an announce already identifies its sender. Directed traffic, prekey bundles, and gateway carriers are also excluded — for directed traffic because fewer hops means fewer deliveries, and for the others because the payload already identifies its owner.
|
||||
|
||||
Payload length remains observable for most traffic: only Noise frames are padded, and the padding itself is inside the signed bytes, so widening its coverage or fixing its length-marker gap is a coordinated cross-platform change rather than a local one.
|
||||
|
||||
Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded.
|
||||
|
||||
## Private Messaging and Courier Delivery
|
||||
|
||||
@@ -11,7 +11,6 @@ import Foundation
|
||||
// Kept local until the test-helper module is split out.
|
||||
struct TestConstants {
|
||||
static let defaultTimeout: TimeInterval = 5.0
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
|
||||
Reference in New Issue
Block a user