mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 09:45:22 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfd4345cf | ||
|
|
32e1c99441 | ||
|
|
399cdf95ab |
@@ -5,20 +5,6 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||
let peerID: PeerID
|
||||
let peerIDData: Data
|
||||
let nickname: String
|
||||
/// Runtime-toggled capability bits (e.g. the internet-gateway toggle)
|
||||
/// ORed into `PeerCapabilities.localSupported` for every announce.
|
||||
let runtimeCapabilities: PeerCapabilities
|
||||
/// Rendezvous cell advertised while bridging; rides announces only
|
||||
/// while the `.bridge` capability is enabled.
|
||||
let bridgeGeohash: String?
|
||||
|
||||
var advertisedCapabilities: PeerCapabilities {
|
||||
PeerCapabilities.localSupported.union(runtimeCapabilities)
|
||||
}
|
||||
|
||||
var advertisedBridgeGeohash: String? {
|
||||
runtimeCapabilities.contains(.bridge) ? bridgeGeohash : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-backed local identity state shared by the transport's message,
|
||||
@@ -26,8 +12,8 @@ struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||
///
|
||||
/// `peerID` and its binary wire representation must change as one unit during
|
||||
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||
/// view of the nickname, identity, and advertised capabilities instead of
|
||||
/// reading independently mutable properties across queues.
|
||||
/// view of the nickname and identity instead of reading three independently
|
||||
/// mutable properties across queues.
|
||||
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var state: BLELocalIdentitySnapshot
|
||||
@@ -39,9 +25,7 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: nickname,
|
||||
runtimeCapabilities: [],
|
||||
bridgeGeohash: nil
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,9 +38,7 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: nickname,
|
||||
runtimeCapabilities: state.runtimeCapabilities,
|
||||
bridgeGeohash: state.bridgeGeohash
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,48 +48,8 @@ final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: state.nickname,
|
||||
runtimeCapabilities: state.runtimeCapabilities,
|
||||
bridgeGeohash: state.bridgeGeohash
|
||||
nickname: state.nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flips a runtime capability bit. Returns whether anything changed.
|
||||
@discardableResult
|
||||
func setCapability(_ capability: PeerCapabilities, enabled: Bool) -> Bool {
|
||||
lock.withLock {
|
||||
var capabilities = state.runtimeCapabilities
|
||||
if enabled {
|
||||
capabilities.insert(capability)
|
||||
} else {
|
||||
capabilities.remove(capability)
|
||||
}
|
||||
guard capabilities != state.runtimeCapabilities else { return false }
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: state.nickname,
|
||||
runtimeCapabilities: capabilities,
|
||||
bridgeGeohash: state.bridgeGeohash
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the bridged rendezvous cell. Returns whether anything changed.
|
||||
@discardableResult
|
||||
func setBridgeGeohash(_ cell: String?) -> Bool {
|
||||
lock.withLock {
|
||||
guard cell != state.bridgeGeohash else { return false }
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: state.nickname,
|
||||
runtimeCapabilities: state.runtimeCapabilities,
|
||||
bridgeGeohash: cell
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLEMeshPingProbe {
|
||||
let peerID: PeerID
|
||||
let sentAt: Date
|
||||
let lifecycleGeneration: UInt64
|
||||
let completion: @MainActor (MeshPingResult?) -> Void
|
||||
let timeout: DispatchWorkItem
|
||||
}
|
||||
|
||||
/// Engine-confined /ping diagnostics state: outstanding probes keyed by
|
||||
/// their unguessable nonce, plus the inbound response budget.
|
||||
///
|
||||
/// The budget is keyed by the ingress link (the directly connected peer
|
||||
/// that delivered the packet), never the packet-claimed sender: pings are
|
||||
/// unsigned, so the claimed sender is attacker-controlled and rotating it
|
||||
/// would reset the budget, turning a directed unencrypted probe into an
|
||||
/// amplification primitive.
|
||||
///
|
||||
/// Pure state — the transport owns packet I/O, timers, and main-actor
|
||||
/// completion delivery around it.
|
||||
struct BLEMeshPingTracker {
|
||||
private var pendingProbes: [Data: BLEMeshPingProbe] = [:]
|
||||
private var responseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
|
||||
mutating func register(_ probe: BLEMeshPingProbe, nonce: Data) {
|
||||
pendingProbes[nonce] = probe
|
||||
}
|
||||
|
||||
/// Resolves a pong against its outstanding probe. The echoed nonce plus
|
||||
/// the sender check bind the reply to the probed peer.
|
||||
mutating func resolve(nonce: Data, from peerID: PeerID) -> BLEMeshPingProbe? {
|
||||
guard pendingProbes[nonce]?.peerID == peerID else { return nil }
|
||||
return pendingProbes.removeValue(forKey: nonce)
|
||||
}
|
||||
|
||||
/// Removes a timed-out probe so its completion can fire once with nil.
|
||||
mutating func expire(nonce: Data) -> BLEMeshPingProbe? {
|
||||
pendingProbes.removeValue(forKey: nonce)
|
||||
}
|
||||
|
||||
/// Whether an inbound ping delivered by this link is within budget.
|
||||
mutating func shouldRespond(toLink linkPeerID: PeerID, now: Date) -> Bool {
|
||||
responseLimiter.shouldRespond(to: linkPeerID, now: now)
|
||||
}
|
||||
|
||||
/// Drops all probes and restores a fresh response budget (panic wipe).
|
||||
/// Returns the orphaned timeout work items for the caller to cancel.
|
||||
mutating func reset() -> [DispatchWorkItem] {
|
||||
let timeouts = pendingProbes.values.map(\.timeout)
|
||||
pendingProbes.removeAll()
|
||||
responseLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||
window: TransportConfig.meshPingInboundWindowSeconds
|
||||
)
|
||||
return timeouts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Lock-backed shared ownership of the peer registry, readable from any
|
||||
/// queue or the main actor without hopping onto a transport queue.
|
||||
///
|
||||
/// Mutations stay serialized by the transport (they only run on its
|
||||
/// queues), so the lock's job is to let the main actor answer questions
|
||||
/// like `isPeerConnected` without blocking behind in-flight transport
|
||||
/// work. Every `BLEPeerRegistry` mutation is a single whole-transition
|
||||
/// method, so a reader between two mutations always observes a valid
|
||||
/// pre- or post-state, never a torn one.
|
||||
///
|
||||
/// Closures passed to `read`/`mutate` run under the (non-recursive) lock
|
||||
/// and must not call back into the store.
|
||||
final class BLEPeerRegistryStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var registry = BLEPeerRegistry()
|
||||
|
||||
/// One consistent view across multiple registry reads.
|
||||
func read<T>(_ body: (BLEPeerRegistry) -> T) -> T {
|
||||
lock.withLock { body(registry) }
|
||||
}
|
||||
|
||||
func mutate<T>(_ body: (inout BLEPeerRegistry) -> T) -> T {
|
||||
lock.withLock { body(®istry) }
|
||||
}
|
||||
|
||||
// MARK: - Single-question reads
|
||||
|
||||
var isEmpty: Bool { read { $0.isEmpty } }
|
||||
var count: Int { read { $0.count } }
|
||||
var peerIDs: [PeerID] { read { $0.peerIDs } }
|
||||
var connectedCount: Int { read { $0.connectedCount } }
|
||||
var connectedPeerIDs: [PeerID] { read { $0.connectedPeerIDs } }
|
||||
var connectedRoutingData: [Data] { read { $0.connectedRoutingData } }
|
||||
var snapshotByID: [PeerID: BLEPeerInfo] { read { $0.snapshotByID } }
|
||||
|
||||
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
||||
read { $0.info(for: peerID) }
|
||||
}
|
||||
|
||||
func isConnected(_ peerID: PeerID) -> Bool {
|
||||
read { $0.isConnected(peerID) }
|
||||
}
|
||||
|
||||
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
||||
read { $0.isReachable(peerID, now: now) }
|
||||
}
|
||||
|
||||
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
||||
read { $0.nickname(for: peerID, connectedOnly: connectedOnly) }
|
||||
}
|
||||
|
||||
func fingerprint(for peerID: PeerID) -> String? {
|
||||
read { $0.fingerprint(for: peerID) }
|
||||
}
|
||||
|
||||
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||
read { $0.capabilities(for: peerID) }
|
||||
}
|
||||
|
||||
func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool {
|
||||
read { $0.capabilitiesWereExplicitlyAdvertised(for: peerID) }
|
||||
}
|
||||
|
||||
func advertisedBridgeGeohash() -> String? {
|
||||
read { $0.advertisedBridgeGeohash() }
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
read { $0.displayNicknames(selfNickname: selfNickname) }
|
||||
}
|
||||
|
||||
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
||||
read { $0.transportSnapshots(selfNickname: selfNickname) }
|
||||
}
|
||||
|
||||
/// Peers advertising `capability` that are reachable now, in one
|
||||
/// consistent view.
|
||||
func reachablePeers(advertising capability: PeerCapabilities, now: Date) -> [PeerID] {
|
||||
read { registry in
|
||||
registry.peers(advertising: capability)
|
||||
.filter { registry.isReachable($0, now: now) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,26 +77,6 @@ struct BLEReceivePipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock-backed traffic-level signal: the receive pipeline records packets,
|
||||
/// and the radio layer (maintenance and scan-duty adaptation on bleQueue)
|
||||
/// reads the level without crossing onto a transport queue.
|
||||
final class BLERecentTrafficMonitor: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var tracker = BLERecentTrafficTracker()
|
||||
|
||||
func recordPacket(at now: Date) {
|
||||
lock.withLock { tracker.recordPacket(at: now) }
|
||||
}
|
||||
|
||||
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
||||
lock.withLock { tracker.hasTraffic(within: seconds, now: now) }
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
lock.withLock { tracker.removeAll() }
|
||||
}
|
||||
}
|
||||
|
||||
struct BLERecentTrafficTracker: Equatable {
|
||||
private var packetTimestamps: [Date] = []
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
||||
import BitFoundation
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
|
||||
/// Optional transport capabilities, discovered with `as?` instead of casting
|
||||
/// to a concrete transport class. `Transport` stays the contract every
|
||||
/// transport genuinely implements; a capability protocol here is the
|
||||
/// contract for one mesh-only feature surface, so app wiring depends on the
|
||||
/// feature it needs rather than on `BLEService` itself.
|
||||
|
||||
/// Radio-state reporting for transports backed by a local radio.
|
||||
protocol BluetoothStateReporting: AnyObject {
|
||||
func getCurrentBluetoothState() -> CBManagerState
|
||||
}
|
||||
|
||||
/// Panic-mode lifecycle for transports that own durable identity state.
|
||||
/// A transport implementing this owns its own restart sequencing:
|
||||
/// `completePanicReset` decides whether services come back, so generic
|
||||
/// `startServices()` calls after a panic belong only to transports that
|
||||
/// don't implement it.
|
||||
protocol PanicResettingTransport: AnyObject {
|
||||
/// Quiesces the radio and drains in-flight work ahead of a panic wipe.
|
||||
func suspendForPanicReset()
|
||||
/// Finishes a panic wipe, optionally restarting services.
|
||||
func completePanicReset(restartServices: Bool)
|
||||
/// Rotates the transport identity as part of a panic reset.
|
||||
func resetIdentityForPanic(currentNickname: String, restartServices: Bool)
|
||||
}
|
||||
|
||||
/// Internet-gateway and geohash-bridge wiring surface (BLE mesh today).
|
||||
/// Everything the gateway/bridge/courier services need from the mesh
|
||||
/// transport, so their bootstrap wiring never touches the concrete class.
|
||||
protocol MeshBridgingTransport: AnyObject {
|
||||
// Runtime-advertised capability bits
|
||||
func setLocalCapability(_ capability: PeerCapabilities, enabled: Bool)
|
||||
func setLocalBridgeGeohash(_ cell: String?)
|
||||
func advertisedBridgeGeohash() -> String?
|
||||
|
||||
// Peers currently advertising bridging roles
|
||||
func reachableGatewayPeers() -> [PeerID]
|
||||
func reachableBridgePeers() -> [PeerID]
|
||||
|
||||
// Gateway carrier packets (mesh <-> Nostr uplink/downlink)
|
||||
@discardableResult
|
||||
func sendNostrCarrier(_ payload: Data, to gatewayPeer: PeerID) -> Bool
|
||||
func broadcastNostrCarrier(_ payload: Data)
|
||||
/// Sink for received carrier packets (set once by app wiring; called on
|
||||
/// the main actor after transport-level checks).
|
||||
var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? { get set }
|
||||
|
||||
// Bridge courier drops (sealed envelopes carried across the bridge)
|
||||
func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope?
|
||||
@discardableResult
|
||||
func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool
|
||||
@discardableResult
|
||||
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool
|
||||
func myNoiseStaticPublicKey() -> Data
|
||||
func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)]
|
||||
/// Fired (off-main) when a signature-verified announce is processed.
|
||||
var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? { get set }
|
||||
}
|
||||
@@ -450,6 +450,3 @@ extension BitchatDelegate {
|
||||
}
|
||||
|
||||
extension BLEService: Transport {}
|
||||
extension BLEService: BluetoothStateReporting {}
|
||||
extension BLEService: PanicResettingTransport {}
|
||||
extension BLEService: MeshBridgingTransport {}
|
||||
|
||||
@@ -6,6 +6,73 @@ 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
|
||||
|
||||
@@ -106,8 +106,8 @@ extension ChatViewModel: ChatLifecycleContext {
|
||||
}
|
||||
|
||||
func refreshBluetoothState() {
|
||||
if let radio = meshService as? BluetoothStateReporting {
|
||||
updateBluetoothState(radio.getCurrentBluetoothState())
|
||||
if let bleService = meshService as? BLEService {
|
||||
updateBluetoothState(bleService.getCurrentBluetoothState())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1564,8 +1564,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
|
||||
// Quiesce the mesh before clearing stores. Identity replacement below
|
||||
// deliberately stays stopped until media deletion and marker commit.
|
||||
if let panicTransport = meshService as? PanicResettingTransport {
|
||||
panicTransport.suspendForPanicReset()
|
||||
if let bleService = meshService as? BLEService {
|
||||
bleService.suspendForPanicReset()
|
||||
} else {
|
||||
meshService.emergencyDisconnectAll()
|
||||
}
|
||||
@@ -1700,8 +1700,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
|
||||
// Replace the BLE identity while keeping the radio stopped. It may
|
||||
// reopen only after the durable panic transaction commits.
|
||||
if let panicTransport = meshService as? PanicResettingTransport {
|
||||
panicTransport.resetIdentityForPanic(
|
||||
if let bleService = meshService as? BLEService {
|
||||
bleService.resetIdentityForPanic(
|
||||
currentNickname: nickname,
|
||||
restartServices: false
|
||||
)
|
||||
@@ -1746,19 +1746,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
|
||||
guard panicCompleted else { return false }
|
||||
|
||||
if let panicTransport = meshService as? PanicResettingTransport {
|
||||
if let bleService = meshService as? BLEService {
|
||||
// Startup recovery reopens admission but leaves actual service
|
||||
// start to the bootstrapper immediately after this method.
|
||||
panicTransport.completePanicReset(
|
||||
bleService.completePanicReset(
|
||||
restartServices: restartServices
|
||||
)
|
||||
}
|
||||
|
||||
if restartServices {
|
||||
// All persistent state and media are gone. Bring each service back
|
||||
// only now, under the new identity — a panic-resetting transport
|
||||
// owns its own restart sequencing above.
|
||||
if !(meshService is PanicResettingTransport) {
|
||||
// only now, under the new identity.
|
||||
if !(meshService is BLEService) {
|
||||
meshService.startServices()
|
||||
}
|
||||
|
||||
|
||||
@@ -195,8 +195,9 @@ private extension ChatViewModelBootstrapper {
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
|
||||
guard let viewModel,
|
||||
let radio = viewModel.meshService as? BluetoothStateReporting else { return }
|
||||
viewModel.updateBluetoothState(radio.getCurrentBluetoothState())
|
||||
let bleService = viewModel.meshService as? BLEService else { return }
|
||||
let state = bleService.getCurrentBluetoothState()
|
||||
viewModel.updateBluetoothState(state)
|
||||
}
|
||||
|
||||
viewModel.nostrRelayManager = NostrRelayManager.shared
|
||||
@@ -330,7 +331,7 @@ private extension ChatViewModelBootstrapper {
|
||||
func configureGateway() {
|
||||
// Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
|
||||
// has no carrier packets to bridge.
|
||||
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
|
||||
guard let bleService = viewModel.meshService as? BLEService else { return }
|
||||
let gateway = GatewayService.shared
|
||||
|
||||
gateway.publishToRelays = { event, geohash in
|
||||
@@ -409,7 +410,7 @@ private extension ChatViewModelBootstrapper {
|
||||
/// transport, the relay manager, location, and the public timeline. Same
|
||||
/// closure-injection style as `configureGateway`.
|
||||
func configureBridge() {
|
||||
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
|
||||
guard let bleService = viewModel.meshService as? BLEService else { return }
|
||||
let bridge = BridgeService.shared
|
||||
let idBridge = viewModel.idBridge
|
||||
|
||||
@@ -544,7 +545,7 @@ private extension ChatViewModelBootstrapper {
|
||||
/// manager, the mesh transport's sealing/opening primitives, the courier
|
||||
/// store, and the message router's deposit path.
|
||||
func configureBridgeCourier() {
|
||||
guard let bleService = viewModel.meshService as? MeshBridgingTransport else { return }
|
||||
guard let bleService = viewModel.meshService as? BLEService else { return }
|
||||
let courier = BridgeCourierService.shared
|
||||
|
||||
courier.bridgeEnabled = { BridgeService.shared.isEnabled }
|
||||
|
||||
@@ -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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!receivedDuplicate)
|
||||
|
||||
@@ -117,7 +117,7 @@ struct BLEServiceCoreTests {
|
||||
|
||||
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.settleTimeout)
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#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.settleTimeout)
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#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.settleTimeout)
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#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.settleTimeout)
|
||||
timeout: TestConstants.shortTimeout)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
@@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests {
|
||||
{
|
||||
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
||||
},
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -321,7 +321,7 @@ struct ChatViewModelCommandTests {
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
let resolved = await TestHelpers.waitUntil({
|
||||
viewModel.getPeerIDForNickname("Alice") == peerID
|
||||
}, timeout: TestConstants.negativeWaitWindow)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#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.negativeWaitWindow)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#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.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#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.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
let acked = await TestHelpers.waitUntil({
|
||||
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(stored)
|
||||
#expect(acked)
|
||||
@@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return name == "Bob"
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(delivered)
|
||||
}
|
||||
@@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
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.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(privateChatUpdated)
|
||||
#expect(conversationStoreUpdated)
|
||||
@@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests {
|
||||
|
||||
let bound = await TestHelpers.waitUntil({
|
||||
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(bound)
|
||||
|
||||
let qr = VerificationService.VerificationQR(
|
||||
@@ -982,7 +982,7 @@ struct ChatViewModelPeerTests {
|
||||
|
||||
let cleaned = await TestHelpers.waitUntil({
|
||||
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
|
||||
#expect(cleaned)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -161,7 +161,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
@@ -229,7 +229,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -245,7 +245,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!delivered)
|
||||
}
|
||||
@@ -293,7 +293,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -310,7 +310,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -355,7 +355,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let freshAnnounce = try #require(
|
||||
@@ -410,7 +410,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let refloodedInCooldown = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announcedAgain)
|
||||
let directAgain = try #require(
|
||||
@@ -434,7 +434,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!handedOverWithoutLinkProof)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -457,7 +457,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let queuedPacket = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -602,14 +602,14 @@ struct CourierEndToEndTests {
|
||||
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(published)
|
||||
return (
|
||||
@@ -124,7 +124,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
|
||||
@@ -138,7 +138,7 @@ struct PrekeyEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -158,7 +158,7 @@ struct PrekeyEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
@@ -328,7 +328,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
||||
}
|
||||
|
||||
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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
|
||||
// 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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
|
||||
// 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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
// 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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
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.settleTimeout
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(restored)
|
||||
}
|
||||
@@ -844,7 +844,7 @@ struct GossipSyncManagerTests {
|
||||
!FileManager.default.fileExists(atPath: fileURL.path)
|
||||
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
|
||||
},
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(erased)
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
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: TestConstants.settleTimeout)
|
||||
_ = decryptResult.wait(timeout: 5)
|
||||
if let promotionResultForCleanup {
|
||||
_ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout)
|
||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,19 +751,15 @@ struct NoiseCoverageTests {
|
||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||
promotionThread.qualityOfService = .userInitiated
|
||||
promotionThread.start()
|
||||
try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success)
|
||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .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: TestConstants.settleTimeout)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||
|
||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||
|
||||
@@ -580,16 +580,8 @@ 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 = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 10.0,
|
||||
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(TestConstants.settleTimeout))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEMeshPingTrackerTests {
|
||||
private func makeProbe(peerID: PeerID) -> BLEMeshPingProbe {
|
||||
BLEMeshPingProbe(
|
||||
peerID: peerID,
|
||||
sentAt: Date(timeIntervalSince1970: 1_000),
|
||||
lifecycleGeneration: 1,
|
||||
completion: { _ in },
|
||||
timeout: DispatchWorkItem {}
|
||||
)
|
||||
}
|
||||
|
||||
@Test func resolveReturnsProbeOnlyForTheProbedPeer() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let nonce = Data([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
tracker.register(makeProbe(peerID: probed), nonce: nonce)
|
||||
|
||||
// A pong claiming the right nonce from the wrong peer must not
|
||||
// consume the probe.
|
||||
let wrongPeer = tracker.resolve(nonce: nonce, from: PeerID(str: "bbbbbbbbbbbbbbbb"))
|
||||
#expect(wrongPeer == nil)
|
||||
let rightPeer = tracker.resolve(nonce: nonce, from: probed)
|
||||
#expect(rightPeer != nil)
|
||||
// Consumed exactly once.
|
||||
let secondResolve = tracker.resolve(nonce: nonce, from: probed)
|
||||
#expect(secondResolve == nil)
|
||||
}
|
||||
|
||||
@Test func expireConsumesTheProbeSoResolveCannotFireTwice() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let nonce = Data([9, 9, 9, 9, 9, 9, 9, 9])
|
||||
let probed = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
tracker.register(makeProbe(peerID: probed), nonce: nonce)
|
||||
|
||||
let firstExpire = tracker.expire(nonce: nonce)
|
||||
#expect(firstExpire != nil)
|
||||
let secondExpire = tracker.expire(nonce: nonce)
|
||||
#expect(secondExpire == nil)
|
||||
let resolveAfterExpire = tracker.resolve(nonce: nonce, from: probed)
|
||||
#expect(resolveAfterExpire == nil)
|
||||
}
|
||||
|
||||
@Test func inboundBudgetIsPerLinkAndBounded() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let now = Date(timeIntervalSince1970: 2_000)
|
||||
let linkA = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
let linkB = PeerID(str: "bbbbbbbbbbbbbbbb")
|
||||
|
||||
var allowedOnA = 0
|
||||
for _ in 0..<(TransportConfig.meshPingInboundMaxPerLink + 5) {
|
||||
if tracker.shouldRespond(toLink: linkA, now: now) { allowedOnA += 1 }
|
||||
}
|
||||
#expect(allowedOnA == TransportConfig.meshPingInboundMaxPerLink)
|
||||
// One saturated link must not consume another link's budget.
|
||||
let allowedOnB = tracker.shouldRespond(toLink: linkB, now: now)
|
||||
#expect(allowedOnB)
|
||||
}
|
||||
|
||||
@Test func resetDropsProbesRestoresBudgetAndHandsBackTimeouts() {
|
||||
var tracker = BLEMeshPingTracker()
|
||||
let now = Date(timeIntervalSince1970: 3_000)
|
||||
let link = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
let nonce = Data([4, 4, 4, 4, 4, 4, 4, 4])
|
||||
tracker.register(makeProbe(peerID: link), nonce: nonce)
|
||||
for _ in 0..<TransportConfig.meshPingInboundMaxPerLink {
|
||||
_ = tracker.shouldRespond(toLink: link, now: now)
|
||||
}
|
||||
let saturated = tracker.shouldRespond(toLink: link, now: now)
|
||||
#expect(!saturated)
|
||||
|
||||
let timeouts = tracker.reset()
|
||||
|
||||
#expect(timeouts.count == 1)
|
||||
let resolveAfterReset = tracker.resolve(nonce: nonce, from: link)
|
||||
#expect(resolveAfterReset == nil)
|
||||
let allowedAfterReset = tracker.shouldRespond(toLink: link, now: now)
|
||||
#expect(allowedAfterReset)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.settleTimeout)
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
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 = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
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: TestConstants.negativeWaitWindow)
|
||||
wait(for: [notified], timeout: 1.0)
|
||||
context.notificationCenter.removeObserver(token)
|
||||
|
||||
XCTAssertFalse(context.service.userTorEnabled)
|
||||
@@ -243,7 +243,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -69,45 +69,25 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
||||
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
)
|
||||
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
|
||||
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
|
||||
var received: [Bool] = []
|
||||
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
||||
defer { cancellable.cancel() }
|
||||
|
||||
let start = Date()
|
||||
monitor.ingest(reachable: false)
|
||||
// Duplicate unsatisfied update mid-window (e.g. an interface detail
|
||||
// change while still offline).
|
||||
clock.now = clock.now.addingTimeInterval(0.1)
|
||||
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.
|
||||
monitor.ingest(reachable: false)
|
||||
// Past the original deadline, so the scheduled flush commits.
|
||||
clock.now = clock.now.addingTimeInterval(0.2)
|
||||
|
||||
// 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 }
|
||||
let committed = await waitUntil(timeout: 2.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
|
||||
@@ -199,7 +179,7 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
@@ -259,13 +239,3 @@ 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: TestConstants.settleTimeout)
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||
#expect(authenticated)
|
||||
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||
{ recorder.generationCount >= 1 },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: 5.0
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#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.negativeWaitWindow
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
@@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests {
|
||||
)
|
||||
let retried = await TestHelpers.waitUntil(
|
||||
{ recorder.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retried)
|
||||
let retryExpired = await TestHelpers.waitUntil(
|
||||
{ !service.hasSession(with: peerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retryExpired)
|
||||
#expect(recorder.timeoutCount == 1)
|
||||
@@ -710,12 +710,7 @@ struct NoiseEncryptionServiceTests {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
// 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,
|
||||
ordinaryResponderHandshakeTimeout: 0.06,
|
||||
ordinaryReconnectRollbackCooldown: 0.3
|
||||
)
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
@@ -746,12 +741,12 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
let restored = await TestHelpers.waitUntil(
|
||||
{ bob.hasEstablishedSession(with: alicePeerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(restored)
|
||||
let callbackArrived = await TestHelpers.waitUntil(
|
||||
{ recovery.timeoutCount == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(callbackArrived)
|
||||
|
||||
@@ -785,13 +780,7 @@ struct NoiseEncryptionServiceTests {
|
||||
let bob = NoiseEncryptionService(
|
||||
keychain: MockKeychain(),
|
||||
ordinaryHandshakeTimeout: 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
|
||||
ordinaryResponderHandshakeTimeout: 0.04
|
||||
)
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
@@ -825,12 +814,12 @@ struct NoiseEncryptionServiceTests {
|
||||
// initiates one bounded convergence retry; drop that message 1 too.
|
||||
let retryPrepared = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retryPrepared)
|
||||
let retryExpired = await TestHelpers.waitUntil(
|
||||
{ !bob.hasSession(with: alicePeerID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#expect(retryExpired)
|
||||
#expect(recovery.timeoutCount == 1)
|
||||
@@ -1037,7 +1026,7 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
let requested = await TestHelpers.waitUntil(
|
||||
{ recovery.messages.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: 1
|
||||
)
|
||||
#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: TestConstants.settleTimeout) {
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
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: TestConstants.settleTimeout) { quietDeliveredAfterBusyCount >= 0 }
|
||||
let quietDelivered = await waitUntil(timeout: 5.0) { 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: TestConstants.settleTimeout) {
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
busyDeliveredCount == busyEvents.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
@@ -1907,7 +1907,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
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: TestConstants.settleTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
#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: TestConstants.settleTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
#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: TestConstants.settleTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
#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: TestConstants.settleTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
#expect(didSend)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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 = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -320,7 +320,7 @@ struct SecureIdentityStateManagerVouchTests {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
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.settleTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
#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 = TestConstants.settleTimeout,
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -11,54 +11,14 @@ 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
|
||||
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
|
||||
/// 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"
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
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(TestConstants.settleTimeout))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -59,24 +59,11 @@ 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(TestConstants.settleTimeout))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# BLE Transport Architecture V3
|
||||
|
||||
The plan of record for restructuring `BLEService` from an 8.3k-line god
|
||||
object into a layered mesh stack. ARCHITECTURE_V2 rebuilt the app layer
|
||||
above the transport and deliberately deferred the transport itself; this
|
||||
document covers that remainder: what already landed, the target shape, and
|
||||
the order for the rest.
|
||||
|
||||
## Why the satellite strategy stalled
|
||||
|
||||
V2's transport approach was to peel pure policies and closure-driven
|
||||
handlers out of `BLEService` while the class kept coordinating. The ~30
|
||||
pure policy structs were a clear win. The five big handler extractions
|
||||
were not: each needed an "environment" of 20–30 closures that weakly
|
||||
capture the service and hop queues back into its state. Logic left, but
|
||||
state ownership and synchronization never moved, so extraction paid a
|
||||
plumbing tax that grew as fast as the logic shrank — the five
|
||||
`make*HandlerEnvironment()` factories alone were ~1.5k lines. The file
|
||||
held ~60 mutable fields across four concurrency domains whose ownership
|
||||
lived in comments, and every new feature added Transport requirements,
|
||||
state maps, and switch cases to the same class.
|
||||
|
||||
Two chronic costs came straight from that structure: queue-order
|
||||
deadlocks (the July 9 main↔bleQueue ABBA freeze), and timing-dependent
|
||||
tests (correctness only observable through real queues and real time).
|
||||
|
||||
## Target shape
|
||||
|
||||
A packet-radio stack with one rule per layer about state and threads:
|
||||
|
||||
1. **`BLELinkLayer`** — the only CoreBluetooth import. Owns both managers,
|
||||
scanning/advertising, duty cycle, connection scheduling, MTU, write
|
||||
and notification backpressure buffers, state restoration. Speaks
|
||||
`LinkEvent` up (link up/down, bytes in, writable) and `LinkCommand`
|
||||
down (send bytes on link, scan/advertise policy). Knows nothing about
|
||||
packets, peers, or Noise. bleQueue-confined. A `SimulatedLinkLayer`
|
||||
implementing the same port gives multi-node tests real topologies with
|
||||
no radios and no wall-clock waits.
|
||||
2. **Mesh engine** — one serial queue owning all protocol state: wire
|
||||
codec, fragmentation, dedup, relay policy, peer registry, topology,
|
||||
gossip sync, Noise orchestration. Synchronous single-writer logic; the
|
||||
pure policy satellites slot in unchanged. Endgame: the engine core
|
||||
becomes `handle(event, now) -> [Effect]` (sans-I/O), which makes the
|
||||
whole mesh property-testable and fuzzable in simulation.
|
||||
3. **Feature modules** — courier, board, prekeys, private media, file
|
||||
transfer, voice, diagnostics, groups, verify/vouch each own their
|
||||
state and register for their message types. A new feature is a new
|
||||
module, not edits to the engine.
|
||||
4. **App boundary** — a small `Transport` core both transports genuinely
|
||||
implement, plus capability protocols discovered with `as?`
|
||||
(`MeshBridgingTransport` etc.), replacing the ~90-requirement
|
||||
god-protocol and its inert defaults.
|
||||
|
||||
### Concurrency contract
|
||||
|
||||
State is owned one of three ways:
|
||||
|
||||
- **Engine-confined** — mutated only on the serial engine queue
|
||||
(`mesh.message`). Cross-thread callers use `onEngine`.
|
||||
- **bleQueue-confined** — link-layer state next to CoreBluetooth objects
|
||||
(link store, write/notification buffers, link-auth maps).
|
||||
- **Lock-backed store** — state with legitimate cross-domain readers
|
||||
(peer registry, local identity/capabilities, traffic monitor). Writes
|
||||
still come from one domain; the lock exists so readers never block on
|
||||
a queue. Every mutation is a single whole-transition method, so
|
||||
readers never observe torn state.
|
||||
|
||||
Sync-edge order (deadlock freedom by construction, debug-enforced in
|
||||
`onEngine`):
|
||||
|
||||
```
|
||||
main / test threads ──sync──▶ engine ──sync──▶ bleQueue
|
||||
└──sync──▶ noise / identity queues (leaves)
|
||||
```
|
||||
|
||||
Nothing may sync-wait in the reverse direction: bleQueue and the crypto
|
||||
queues reach the engine only via `async`, and nothing sync-dispatches to
|
||||
main. Two subtleties worth knowing:
|
||||
|
||||
- A closure executed inside a noise-manager critical section entered
|
||||
*from* an engine slot may touch engine state directly (the blocked
|
||||
slot makes it exclusive) but must never sync-re-enter the engine —
|
||||
that is a self-deadlock.
|
||||
- bleQueue critical sections (e.g. the verified-announce link rebind)
|
||||
must receive engine-derived values as arguments rather than fetching
|
||||
them through `onEngine`.
|
||||
|
||||
## What landed in this pass
|
||||
|
||||
- **Lock-backed peer state** (`BLEPeerRegistryStore`): every main-actor
|
||||
Transport read (`isPeerConnected`, nicknames, snapshots, capability
|
||||
queries) reads a lock, not a queue. Runtime capability bits moved into
|
||||
`BLELocalIdentityStateStore` beside the identity they ride announces
|
||||
with.
|
||||
- **bleQueue owns the link buffers**: `pendingPeripheralWrites`,
|
||||
`pendingNotifications`, `pendingWriteBuffers` are bleQueue-confined
|
||||
(their producers and drains already ran there); the notification drain
|
||||
no longer invokes CoreBluetooth from a transport queue.
|
||||
- **One serial engine queue**: the concurrent message queue and the
|
||||
collections queue it guarded state with are one serial domain; every
|
||||
barrier flag and per-field ownership comment deleted; ~98 cross-queue
|
||||
hops removed. `onEngine` documents and debug-enforces the sync-edge
|
||||
order — and its trap caught two latent inversions during migration
|
||||
(the announce-rebind path and the noise session-generation closures).
|
||||
- **Capability ports**: gateway/bridge/courier wiring, the panic
|
||||
lifecycle, and radio-state reads go through `MeshBridgingTransport`,
|
||||
`PanicResettingTransport`, and `BluetoothStateReporting`; no app code
|
||||
casts to `BLEService` anymore.
|
||||
- **First feature-owned state**: `BLEMeshPingTracker` holds the /ping
|
||||
probe map and per-link response budget as pure state with unit tests —
|
||||
the template for peeling the remaining features.
|
||||
|
||||
Full suite green throughout (1,953 tests), identical wall-clock — BLE
|
||||
throughput is nowhere near what one serial queue sustains.
|
||||
|
||||
## Remaining roadmap (in order)
|
||||
|
||||
1. **Feature peeling.** Move each feature's state maps and handlers into
|
||||
a module in the `BLEMeshPingTracker` mold: private media (six
|
||||
generation-keyed maps + policy resolution — its main-actor reads
|
||||
become a lock-backed store inside the module), courier, prekeys,
|
||||
board, voice, file transfer, groups. The engine keeps a registry of
|
||||
handled message types instead of a giant switch. Each module lands as
|
||||
its own PR with its state's invariants unit-tested.
|
||||
2. **Transport protocol split.** Continue what the capability ports
|
||||
started: `Transport` shrinks to lifecycle + identity + snapshots +
|
||||
basic messaging; files/voice/courier/board/diagnostics/verification
|
||||
become capability protocols; `NostrTransport` drops its inert stubs;
|
||||
coordinators declare the capability they need instead of receiving
|
||||
the whole god-protocol (~48 call sites across 14 files).
|
||||
3. **Link-layer extraction.** With the file slimmed, move the CB
|
||||
delegates, scheduling, duty cycle, and buffers behind
|
||||
`LinkEvent`/`LinkCommand` ports. Decide the link-auth boundary here:
|
||||
`noiseAuthenticatedLinkOwners` and the rebind containment rules are
|
||||
mesh security state that currently lives on bleQueue for atomicity
|
||||
with bindings — the port design must keep "binding + auth check" one
|
||||
critical section or make bindings engine-owned.
|
||||
4. **Sans-I/O engine core + simulator.** Make the engine formally
|
||||
`handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and
|
||||
move the multi-node E2E suite onto deterministic simulation (no
|
||||
`waitUntil`, no timing hygiene battles). Property tests become
|
||||
possible: relay-storm bounds, partition-heal convergence, dedup
|
||||
soundness under duplicate floods.
|
||||
|
||||
## What this is not
|
||||
|
||||
No wire changes: packet formats, signing (padding is signed), the
|
||||
peerID identity binding, and courier tag construction are untouched —
|
||||
see the wire-landmines notes before assuming any of that is local.
|
||||
@@ -26,13 +26,22 @@ 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,6 +11,7 @@ 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