bitchat/bitchatTests/Simulation/SimulatedMeshTests.swift
jack 6ef3945179
Deflake gift-wrap tests: settle deadlines, not latency budgets (#1651)
* Deflake gift-wrap tests: settle deadlines, not latency budgets

The gift-wrap round-trip tests failed three CI runs this week
(handleGiftWrap_privateMessageStoresConversationAndMapping,
handleGiftWrap_deliveredAckUpdatesExistingMessage,
handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with
the same signature: the async NIP-17 unwrap missed a 5s wait on a
loaded runner. 40 local iterations of both suites pass clean — the
failures are scheduler starvation, exactly the class
TestConstants.settleTimeout documents.

Every positive wait in ChatViewModelExtensionsTests and
ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the
explicit 5.0s literals on the gift-wrap waits, the longTimeout media
waits, and the bare-default channel-switch waits. All are
expected-true waits, so passing runs return immediately and never pay
the deadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake SimulatedMesh TTL budget: settle discovery before the baseline

publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its
frame baseline after a single 2s advance, but discovery is not quiet
by then: every first-seen peer schedules an afterglow re-announce at a
random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can
cascade another relay round. Whether that traffic lands before or
after the snapshot depends on the draw — CI measured the "single
message" at 14 and 18 frames against a budget of 12.

The test now advances until the mesh goes a full window with no new
frames before taking the baseline, so the budget only ever measures
the message under test. 30 local iterations green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 07:56:12 +02:00

345 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// Deterministic multi-node mesh tests over the real engine (no
/// CoreBluetooth, no wall-clock waits): announces bind simulated links,
/// signatures verify, Noise sessions establish, and rotation rebinds run
/// the same engine slots a radio would drive. See SimulatedMesh for the
/// fidelity boundary.
@Suite(.serialized)
struct SimulatedMeshTests {
@Test
func announceExchangeBindsLinksAndConnectsPeers() {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connect(0, 1)
mesh.announceAll()
// Raw direct announces bind each directed edge to the sender.
#expect(b.service._test_centralBinding(mesh.linkUUID(from: 0, at: 1)) == a.service.myPeerID)
#expect(a.service._test_centralBinding(mesh.linkUUID(from: 1, at: 0)) == b.service.myPeerID)
// Verified announces register connected peers on both sides.
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
#expect(b.service.getConnectedPeers().contains(a.service.myPeerID))
}
@Test
func noiseSessionEstablishesEndToEnd() {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connect(0, 1)
mesh.announceAll()
// Handshake initiation and any deferred retries ride engine
// timers; settle until both directions hold (normally 1 round).
mesh.settleUntil {
a.service.canDeliverSecurely(to: b.service.myPeerID)
&& b.service.canDeliverSecurely(to: a.service.myPeerID)
}
#expect(a.service.canDeliverSecurely(to: b.service.myPeerID))
#expect(b.service.canDeliverSecurely(to: a.service.myPeerID))
}
@Test
func publicMessageRelaysAcrossLineTopologyWithinTTLBudget() async {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
_ = mesh.addNode(nickname: "bob")
let c = mesh.addNode(nickname: "carol")
mesh.connect(0, 1)
mesh.connect(1, 2)
mesh.announceAll()
// Discovery is not quiet after one advance: every first-seen peer
// schedules an afterglow re-announce at a RANDOM 0.30.6s delay
// (BLEAnnounceHandler), and each of those can cascade another relay
// round. Whether that traffic lands before or after a one-shot
// baseline snapshot depends on the draw the budget assertion below
// flaked on CI at 14 and 18 frames for exactly that reason. Advance
// until the mesh goes a full window with no new frames, so the
// baseline only ever measures the message under test.
var settled = mesh.deliveredFrameCount
for _ in 0..<20 {
mesh.advanceTime(by: 2)
let now = mesh.deliveredFrameCount
if now == settled { break }
settled = now
}
let baseline = mesh.deliveredFrameCount
let capture = TransportEventCapture()
c.service.eventDelegate = capture
a.service.sendMessage("hello line", mentions: [])
mesh.pump()
// Relay jitter defers B's forward; release it (twice: the relay's
// own broadcast may schedule follow-on work).
mesh.advanceTime(by: 2)
mesh.advanceTime(by: 2)
let arrived = await capture.drainedPublicMessageCount(content: "hello line") == 1
#expect(arrived)
// Storm bound: a single public message across one relay hop must
// not multiply into more than a handful of frames.
#expect(mesh.deliveredFrameCount - baseline <= 12)
}
@Test
func duplicateFloodIsDeliveredOnce() async {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connect(0, 1)
mesh.announceAll()
let capture = TransportEventCapture()
b.service.eventDelegate = capture
let packet = BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: a.service.myPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data("flooded".utf8),
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
let signed = a.service.signPacketForBroadcast(packet)
let link = BLEIngressLinkID.central(mesh.linkUUID(from: 0, at: 1))
for _ in 0..<8 {
b.service._test_ingestFrame(signed, link: link)
}
mesh.pump()
mesh.advanceTime(by: 2)
let deliveredOnce = await capture.drainedPublicMessageCount(content: "flooded") == 1
#expect(deliveredOnce)
}
@Test
func linkDropEventRetiresBindingAndReconnectHeals() {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connect(0, 1)
mesh.announceAll()
let bobLinkOnAlice = mesh.linkUUID(from: 1, at: 0)
#expect(a.service._test_centralBinding(bobLinkOnAlice) == b.service.myPeerID)
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
// The link layer reports the drop through the same port
// CoreBluetooth's didUnsubscribe uses: identity retirement and
// last-link peer bookkeeping are engine work.
a.service.emitLinkEvent(.centralLinkEnded(centralUUID: bobLinkOnAlice))
a.service._test_fenceEngine()
#expect(a.service._test_centralBinding(bobLinkOnAlice) == nil)
#expect(!a.service.getConnectedPeers().contains(b.service.myPeerID))
// A fresh announce over the (re-established) link binds and
// reconnects the same heal path a real reconnection drives.
// (The announce throttle runs on wall clock; model elapsed time.)
b.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
mesh.settleUntil {
a.service.getConnectedPeers().contains(b.service.myPeerID)
}
#expect(a.service._test_centralBinding(bobLinkOnAlice) == b.service.myPeerID)
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
}
/// Issue #1538: with two live links to the same phone, a panic
/// rotation used to heal only the link the verified announce arrived
/// on. The second link kept its binding to
/// the retired identity, which therefore stayed in the peer list as a
/// ghost and, worse, kept being refreshed by the *new* identity's
/// traffic (a bound link attributes non-announce frames to its bound
/// peer, so the dead ID looked alive for as long as the link lived).
@Test
func duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks() {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connectDuplicateLinks(0, 1)
mesh.announceAll()
let centralLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0))
let duplicateLink = BLEIngressLinkID.central(mesh.duplicateLinkUUID(from: 1, at: 0))
let oldBobID = b.service.myPeerID
// Both links bind to bob: raw direct announces bind unbound links,
// and that happens before duplicate suppression.
#expect(a.service._test_linkBinding(centralLink) == oldBobID)
#expect(a.service._test_linkBinding(duplicateLink) == oldBobID)
b.service.suspendForPanicReset()
b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false)
b.service.completePanicReset(restartServices: false)
mesh.pump()
let newBobID = b.service.myPeerID
#expect(newBobID != oldBobID)
// One verified direct announce must retire the old identity
// outright no ghost survives on the link it did not arrive on.
mesh.forceAnnounce(from: 1)
mesh.settleUntil { !a.service._test_knownPeerIDs().contains(oldBobID) }
#expect(!a.service._test_knownPeerIDs().contains(oldBobID))
#expect(a.service._test_linkBinding(centralLink) != oldBobID)
#expect(a.service._test_linkBinding(duplicateLink) != oldBobID)
// Both links converge onto the new identity as its announces land
// (the released link binds through the ordinary unbound-link path,
// so no containment rule has to be relaxed).
for _ in 0..<4 {
b.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
mesh.advanceTime(by: 1)
}
#expect(a.service._test_linkBinding(centralLink) == newBobID)
#expect(a.service._test_linkBinding(duplicateLink) == newBobID)
#expect(a.service.getConnectedPeers() == [newBobID])
}
/// The #1401 containment rule, pinned against the attack the #1538 fix
/// had to avoid re-opening: a captured verified direct announce replayed
/// onto a link the attacker controls must NOT bind that link to the
/// victim while the victim holds a live link of its own and must not
/// evict the victim either (the rotation release only runs after a
/// rebind the containment actually permitted).
@Test
func replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim() {
let mesh = SimulatedMesh()
let alice = mesh.addNode(nickname: "alice")
let bob = mesh.addNode(nickname: "bob")
let mallory = mesh.addNode(nickname: "mallory")
mesh.connect(0, 1)
mesh.connect(0, 2)
mesh.announceAll()
let bobLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0))
let malloryLink = BLEIngressLinkID.central(mesh.linkUUID(from: 2, at: 0))
#expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID)
#expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID)
// Mallory captures a signed direct announce alice has NOT seen, so
// duplicate suppression cannot mask the containment check: bob
// announces while out of alice's range, and mallory replays it on
// her own link. Directness is forgeable; the signature is real.
mesh.silence(0, 1)
bob.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
let replay = mesh.emittedPackets(from: 1).last {
$0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault
}
guard let replay else {
Issue.record("bob emitted no direct announce to capture")
return
}
alice.service._test_ingestFrame(replay, link: malloryLink)
mesh.pump()
mesh.advanceTime(by: 1)
// The link is not stolen, and bob keeps both his binding and his
// place in the peer list.
#expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID)
#expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID)
#expect(alice.service._test_knownPeerIDs().contains(bob.service.myPeerID))
#expect(alice.service.getConnectedPeers().contains(bob.service.myPeerID))
// Positive control proves the refusal above was the containment
// rule and not duplicate suppression: once bob holds no live link,
// the very same replayed announce on the very same link does take
// effect. (Long-standing accepted residual: a stolen link carries
// only Noise ciphertext, and the rebind retires the link's proof.)
alice.service.emitLinkEvent(.centralLinkEnded(centralUUID: mesh.linkUUID(from: 1, at: 0)))
alice.service._test_fenceEngine()
bob.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
let secondReplay = mesh.emittedPackets(from: 1).last {
$0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault
}
#expect(secondReplay?.timestamp != replay.timestamp)
if let secondReplay {
alice.service._test_ingestFrame(secondReplay, link: malloryLink)
mesh.pump()
mesh.advanceTime(by: 1)
}
#expect(alice.service._test_linkBinding(malloryLink) == bob.service.myPeerID)
}
@Test
func panicRotationRebindsSurvivorExactlyOnceAndStays() {
let mesh = SimulatedMesh()
let a = mesh.addNode(nickname: "alice")
let b = mesh.addNode(nickname: "bob")
mesh.connect(0, 1)
mesh.announceAll()
mesh.advanceTime(by: 2)
let oldBobID = b.service.myPeerID
let bobLinkOnAlice = mesh.linkUUID(from: 1, at: 0)
#expect(a.service._test_centralBinding(bobLinkOnAlice) == oldBobID)
// Bob panics: the production sequence suspend, rotate the whole
// identity, commit over the same simulated link.
b.service.suspendForPanicReset()
b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false)
b.service.completePanicReset(restartServices: false)
mesh.pump()
let newBobID = b.service.myPeerID
#expect(newBobID != oldBobID)
// His first verified direct announce heals the stale binding in
// one engine slot on the survivor.
mesh.forceAnnounce(from: 1)
mesh.advanceTime(by: 2)
#expect(a.service._test_centralBinding(bobLinkOnAlice) == newBobID)
// Containment: further announces (and the rebind cooldown) leave
// the healed binding alone no flip-flop back to the dead ID.
// (Reset the wall-clock announce throttles so these actually send.)
b.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 1)
a.service._test_resetAnnounceThrottle()
mesh.forceAnnounce(from: 0)
mesh.advanceTime(by: 2)
#expect(a.service._test_centralBinding(bobLinkOnAlice) == newBobID)
#expect(a.service.getConnectedPeers().contains(newBobID))
}
}
/// Captures `.publicMessageReceived` transport events. Delivery crosses the main
/// actor (`notifyUI`), so counting first drains that hop a bounded number
/// of main-actor round-trips, never a wall-clock wait (the mesh is already
/// quiescent when this is called; only the queued MainActor task remains).
private final class TransportEventCapture: TransportEventDelegate, @unchecked Sendable {
private let lock = NSLock()
private var publicMessages: [String] = []
func didReceiveTransportEvent(_ event: TransportEvent) {
guard case let .publicMessageReceived(_, _, content, _, _) = event else { return }
lock.lock()
publicMessages.append(content)
lock.unlock()
}
private func count(content: String) -> Int {
lock.lock()
defer { lock.unlock() }
return publicMessages.filter { $0 == content }.count
}
func drainedPublicMessageCount(content: String, drains: Int = 50) async -> Int {
for _ in 0..<drains {
if count(content: content) > 0 { break }
await MainActor.run {}
}
return count(content: content)
}
}