Compare commits

..
Author SHA1 Message Date
jackandClaude Opus 5 cdfd4345cf Apply the origin-TTL draw everywhere, and stop overclaiming it
Review was right on both counts, and the second one matters more than the
first.

**It was wired into exactly one send path.** Public text drew a TTL;
voice, broadcast files, group messages, board posts and leave all still
originated at the fixed maximum — so those were still perfectly marked as
authored-here, while the privacy assessment said broadcasts were
randomized. A policy that exists but is not applied is worse than none,
because it reads as solved. All six authored-broadcast paths now draw.

Live voice draws **once per talk burst**, not per frame. At ~15 frames a
second a per-frame draw hands an observer the range maximum almost
immediately, so it would have cost reach and bought nothing. A burst is
now one sample, the same as a text message.

Deliberately still fixed, each for a reason now written down: announces
(link binding reads ttl == max as "direct link", and an announce already
names its sender), directed traffic (fewer hops means fewer deliveries —
a real trade that deserves its own change), prekey bundles and gateway
carriers (the payload already identifies its owner; a carrier is a
re-broadcast, not authorship).

**The docs claimed more than the mechanism delivers.** Relays strictly
decrement — every branch of RelayController emits ttlLimit - 1 — so the
top of the range can still only come from an origin. With three values
that is one message in three, and 1 - (2/3)^k, so roughly 87% of senders
are self-identified within five messages. It meaningfully protects an
occasional sender and barely protects a chatty one. Removing the marker
outright needs relays to sometimes not decrement, which trades against
TTL's job as the loop bound, so it is named as follow-up rather than
implied to be done. TransportConfig and the privacy assessment now say
this instead of implying the marker is gone.

Added a wiring guard that reads BLEService and fails if an authored
broadcast origination site uses the fixed maximum without being on an
explicit exclusion list with a reason. Verified it fails: injecting the
old fixed TTL back into the group-message path was caught with file and
line, and it went green again on revert. That is the specific regression
this had, so it is the specific regression now covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:50:30 +01:00
jack 32e1c99441 Merge remote-tracking branch 'origin/main' into harden/radio-metadata 2026-07-26 22:42:09 +01:00
jackandClaude Opus 5 399cdf95ab Stop broadcasting the adjacency graph and the authorship marker
Two radio-layer metadata leaks that need no cross-platform agreement,
because both only change what this device chooses to emit.

**Announces no longer carry the neighbour list.** The TLV held up to ten
8-byte peer IDs, so a *single* passive receiver could reconstruct the local
adjacency graph — who is standing next to whom — with no need for several
receivers or RSSI trilateration. In a crowd that is the most sensitive
thing the radio layer gives away, and unlike the identity keys it is not
required for the protocol to work.

Backward compatible in both directions: an empty list omits the TLV
entirely rather than emitting a zero-length one, the decoder already treats
its absence as "no topology offered", and lists from other peers are still
parsed so a mixed network behaves sensibly.

The cost is source routing. MeshTopologyTracker builds its adjacency map
from these lists, so with everyone silent there are no routes to compute
and directed traffic floods instead — which is already the documented
fallback whenever a route fails. More airtime for directed sends in dense
meshes; no correctness change. Left as a TransportConfig constant rather
than a user setting because it is a protocol trade-off, not a preference,
and flipping it back is one line.

**Public broadcasts no longer always originate at the maximum TTL.**
`ttl == messageTTLDefault` was a reliable "this device wrote it" marker to
any direct listener, which discloses authorship rather than mere presence.
Origin TTL is now drawn from 5...7: in a dense graph relays already 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.

Signature-safe and needs no agreement: TTL is excluded from the signed
bytes (toBinaryDataForSigning zeroes it so relays can decrement), so a peer
on any version just sees a smaller starting TTL and relays it normally. The
floor is not below the dense-graph clamp, since lower would cost reach
without buying ambiguity that clamp does not already provide.

Announces deliberately keep the fixed TTL: three link-binding paths read a
maximum-TTL announce as "direct link", and an announce's sender ID already
identifies the device, so there is nothing to hide and something to break.

**Not done here: padding.** Extending padding beyond Noise frames, and
fixing the gap where a frame needing over 255 bytes of padding is emitted
unpadded, both looked unilateral but are not. `toBinaryDataForSigning`
encodes with padding enabled, so the padding bytes are inside the signed
material for every signed packet — changing the algorithm changes the
signed byte stream and breaks signature verification against any peer that
has not changed it identically. That makes it a coordinated wire change;
recorded in the privacy assessment and in #1487's open questions rather
than attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:03:35 +02:00
43 changed files with 989 additions and 1342 deletions
@@ -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(&registry) }
}
// 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 }
}
-3
View File
@@ -450,6 +450,3 @@ extension BitchatDelegate {
}
extension BLEService: Transport {}
extension BLEService: BluetoothStateReporting {}
extension BLEService: PanicResettingTransport {}
extension BLEService: MeshBridgingTransport {}
+67
View File
@@ -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())
}
}
+8 -9
View File
@@ -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 }
+5 -5
View File
@@ -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
+10 -10
View File
@@ -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)
}
+15 -15
View File
@@ -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))
+10 -10
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+5 -9
View File
@@ -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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
+2 -42
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
-149
View File
@@ -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 2030 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.
+10 -1
View File
@@ -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"