mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-18 07:33:46 +00:00
Merge eeb877c6a9d049326eb7201162568b95e9f09f62 into 9b84b361225facd8e623f25d76f889d3dc54a879
This commit is contained in:
commit
a3d62f85da
@ -327,7 +327,14 @@ final class BLEService: NSObject {
|
||||
|
||||
// Simple announce throttling
|
||||
private let announceThrottle = BLEAnnounceThrottle()
|
||||
|
||||
/// Clock backing announce admission. Injectable so a test harness that
|
||||
/// advances only simulated time can move the throttle window too;
|
||||
/// production passes `Date.init`. Without this the admission decision
|
||||
/// reads the runner's wall clock while the harness's clock stands still,
|
||||
/// which makes the simulated-mesh storm bound a measure of machine speed
|
||||
/// rather than relay behaviour.
|
||||
private let dateProvider: () -> Date
|
||||
|
||||
// Application state tracking (thread-safe)
|
||||
#if os(iOS)
|
||||
var isAppActive: Bool = true // Assume active initially
|
||||
@ -509,9 +516,11 @@ final class BLEService: NSObject {
|
||||
startSuspendedForPanicRecovery: Bool = false,
|
||||
noiseResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler()
|
||||
engineScheduler: BLEEngineScheduling = BLEEngineDispatchScheduler(),
|
||||
dateProvider: @escaping () -> Date = Date.init
|
||||
) {
|
||||
self.engineScheduler = engineScheduler
|
||||
self.dateProvider = dateProvider
|
||||
self.keychain = keychain
|
||||
self.idBridge = idBridge
|
||||
self.incomingFileStore = incomingFileStore
|
||||
@ -2757,7 +2766,7 @@ final class BLEService: NSObject {
|
||||
// after this announce was scheduled but before it runs.
|
||||
guard !isPanicSuspended else { return }
|
||||
// Throttle announces to prevent flooding
|
||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||
if !announceThrottle.shouldSend(force: forceSend, now: dateProvider()) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -3097,9 +3106,11 @@ extension BLEService {
|
||||
onEngine { sendAnnounceNow(forceSend: true) }
|
||||
}
|
||||
|
||||
/// Clears the announce throttle's wall-clock debt — the simulator's
|
||||
/// stand-in for "enough real time has passed", since scheduler time
|
||||
/// cannot move the throttle's Date-based window. Deliberately NOT
|
||||
/// Clears the announce throttle's debt so an announce issued at the
|
||||
/// same instant as the previous one is admitted. With an injected
|
||||
/// clock the simulator can also just advance time past the window;
|
||||
/// this stays for the cases that want a second announce *without*
|
||||
/// moving time (and so releasing scheduled work). Deliberately NOT
|
||||
/// part of `_test_forceAnnounce`: the panic-rotation mesh test relies
|
||||
/// on the production panic path performing its own reset, and a
|
||||
/// blanket reset here would mask that regression.
|
||||
|
||||
@ -24,6 +24,17 @@ final class BLEEngineManualScheduler: BLEEngineScheduling, @unchecked Sendable {
|
||||
lock.withLock { pending.count }
|
||||
}
|
||||
|
||||
/// The same counter `advance(by:)` moves, exposed as a `Date` so
|
||||
/// production code whose decisions are windowed on elapsed time (the
|
||||
/// announce throttle) can be driven by test time instead of the
|
||||
/// runner's speed. Anchored at a plausible wall-clock instant rather
|
||||
/// than the epoch so any timestamp derived from it stays sane.
|
||||
var currentDate: Date {
|
||||
lock.withLock { Self.epoch.addingTimeInterval(now) }
|
||||
}
|
||||
|
||||
private static let epoch = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
|
||||
/// Advances the clock, releasing due work in deadline order.
|
||||
/// Cancellation keeps its production semantics: dispatch skips a
|
||||
/// cancelled `DispatchWorkItem` at execution.
|
||||
|
||||
@ -29,6 +29,29 @@ final class SimulatedMesh {
|
||||
private var pendingDeliveries: [(from: Int, packet: BitchatPacket)] = []
|
||||
/// Total (packet, receiving-node) deliveries pumped — the storm bound.
|
||||
private(set) var deliveredFrameCount = 0
|
||||
/// The same deliveries split by packet type. A whole-mesh frame budget
|
||||
/// silently includes announce traffic, and announce *admission* is the one
|
||||
/// decision the manual scheduler does not own: `sendAnnounceNow` asks
|
||||
/// `announceThrottle.shouldSend(force:now: Date())` (BLEService), so
|
||||
/// whether a scheduled re-announce reaches the wire depends on real
|
||||
/// elapsed seconds, not on `advanceTime`. Burn 0.2s of wall clock in the
|
||||
/// middle of a test — the forced-announce window is 0.15s — and announce
|
||||
/// deliveries rise (measured: 22 → 36) while every other type is
|
||||
/// unchanged. A test that drains the mesh to quiet before it measures
|
||||
/// absorbs that into its baseline; one that cannot has a budget moving
|
||||
/// with runner load. Counting per type lets a test bound the traffic it
|
||||
/// is actually about either way.
|
||||
private var deliveredFrameCountsByType: [UInt8: Int] = [:]
|
||||
|
||||
/// Deliveries of one packet type — the storm bound for a specific kind of
|
||||
/// traffic, immune to unrelated announce frames.
|
||||
/// Unlocked deliberately, matching `deliveredFrameCount` above: both
|
||||
/// counters are written only by `pump` on the test thread (the outbound
|
||||
/// tap touches `pendingDeliveries`/`emitted`, never these). Taking the
|
||||
/// lock here would advertise a protection the writer does not hold.
|
||||
func deliveredFrameCount(ofType type: MessageType) -> Int {
|
||||
deliveredFrameCountsByType[type.rawValue] ?? 0
|
||||
}
|
||||
|
||||
private(set) var nodes: [Node] = []
|
||||
private var neighbors: [Set<Int>] = []
|
||||
@ -54,7 +77,11 @@ final class SimulatedMesh {
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false,
|
||||
engineScheduler: scheduler
|
||||
engineScheduler: scheduler,
|
||||
// Announce admission reads this, so the throttle window moves
|
||||
// with `advanceTime` instead of with however long the runner
|
||||
// took to get here.
|
||||
dateProvider: { scheduler.currentDate }
|
||||
)
|
||||
let index = nodes.count
|
||||
let node = Node(service: service, scheduler: scheduler)
|
||||
@ -68,9 +95,8 @@ final class SimulatedMesh {
|
||||
// The tap must be live before `setNickname` below: setNickname
|
||||
// force-announces asynchronously on the engine, and if that slot
|
||||
// ran in the gap before a later tap install, the announce was
|
||||
// emitted invisibly while still stamping the wall-clock announce
|
||||
// throttle — swallowing `announceAll`'s forced announce on a
|
||||
// starved runner (the CI flake this ordering fixes).
|
||||
// emitted invisibly while still stamping the announce throttle —
|
||||
// swallowing `announceAll`'s forced announce.
|
||||
service._test_onOutboundPacket = { [weak self] packet in
|
||||
// Runs on the sender's engine; only buffer here — delivering
|
||||
// inline would nest one engine inside another.
|
||||
@ -168,6 +194,7 @@ final class SimulatedMesh {
|
||||
for receiver in neighbors[from] {
|
||||
for link in links(from: from, at: receiver) {
|
||||
deliveredFrameCount += 1
|
||||
deliveredFrameCountsByType[packet.type, default: 0] += 1
|
||||
nodes[receiver].service._test_ingestFrame(packet, link: link)
|
||||
}
|
||||
}
|
||||
@ -186,11 +213,12 @@ final class SimulatedMesh {
|
||||
|
||||
/// Full discovery round: every node announces, traffic settles.
|
||||
///
|
||||
/// Resets each node's announce throttle first: the throttle window is
|
||||
/// wall-clock, so any announce that already ran (setNickname's, in
|
||||
/// `addNode`) would otherwise swallow this forced one whenever the two
|
||||
/// land within the forced minimum interval — which is always, on any
|
||||
/// runner. `forceAnnounce(from:)` deliberately does NOT reset — the
|
||||
/// Resets each node's announce throttle first: `setNickname`'s announce
|
||||
/// in `addNode` lands at the same simulated instant as this one, well
|
||||
/// inside the forced minimum interval, and would otherwise swallow it.
|
||||
/// (Advancing time would clear the window too, but it would also
|
||||
/// release scheduled work that callers of `announceAll` have not asked
|
||||
/// for yet.) `forceAnnounce(from:)` deliberately does NOT reset — the
|
||||
/// panic-rotation tests pin the production reset behavior through it.
|
||||
func announceAll() {
|
||||
for node in nodes {
|
||||
|
||||
@ -63,7 +63,8 @@ struct SimulatedMeshTests {
|
||||
// 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.
|
||||
// baseline only ever measures the message under test. Quiescence is
|
||||
// judged on every frame; the baseline itself is taken per type.
|
||||
var settled = mesh.deliveredFrameCount
|
||||
for _ in 0..<20 {
|
||||
mesh.advanceTime(by: 2)
|
||||
@ -71,7 +72,7 @@ struct SimulatedMeshTests {
|
||||
if now == settled { break }
|
||||
settled = now
|
||||
}
|
||||
let baseline = mesh.deliveredFrameCount
|
||||
let baseline = mesh.deliveredFrameCount(ofType: .message)
|
||||
|
||||
let capture = TransportEventCapture()
|
||||
c.service.eventDelegate = capture
|
||||
@ -84,9 +85,57 @@ struct SimulatedMeshTests {
|
||||
|
||||
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)
|
||||
// Storm bound: a single public message across one relay hop must not
|
||||
// multiply into more than a handful of frames. Counted per type so
|
||||
// the budget measures relay fan-out and nothing else. The whole-mesh
|
||||
// total also carries announces, and announce admission is the one
|
||||
// decision the manual scheduler does not own — `sendAnnounceNow` asks
|
||||
// `announceThrottle.shouldSend(force:now: Date())` — so announce
|
||||
// volume tracks real elapsed seconds. The settle loop above keeps
|
||||
// that traffic in the baseline rather than in the delta: burning 0.2s
|
||||
// of wall clock before it moves the baseline 66 → 80 frames and
|
||||
// leaves this delta at 4. Per-type counting is what makes the
|
||||
// assertion say which traffic it bounds.
|
||||
#expect(mesh.deliveredFrameCount(ofType: .message) - baseline <= 8)
|
||||
}
|
||||
|
||||
/// Guards the storm bound above. Announce admission used to consult the
|
||||
/// wall clock while the harness advanced only simulated time, so on a
|
||||
/// loaded runner the 0.15 s forced-minimum window elapsed *between*
|
||||
/// announces that the simulation places at one instant. The extra
|
||||
/// announces that let through fanned out to every neighbour and pushed
|
||||
/// `deliveredFrameCount` past the bound — the test measured machine
|
||||
/// speed, not relay behaviour. Admission must move with simulated time
|
||||
/// and only with simulated time.
|
||||
///
|
||||
/// A lone node keeps this exact: nothing answers it, so every emitted
|
||||
/// announce is one its own throttle admitted.
|
||||
@Test
|
||||
func announceAdmissionFollowsSimulatedTimeNotWallClock() {
|
||||
let mesh = SimulatedMesh()
|
||||
_ = mesh.addNode(nickname: "alice")
|
||||
mesh.announceAll()
|
||||
|
||||
func announceCount() -> Int {
|
||||
mesh.emittedPackets(from: 0)
|
||||
.filter { $0.type == MessageType.announce.rawValue }
|
||||
.count
|
||||
}
|
||||
|
||||
// Same simulated instant: refused, however many real milliseconds
|
||||
// the runner burned between `announceAll` and here.
|
||||
let admitted = announceCount()
|
||||
mesh.forceAnnounce(from: 0)
|
||||
#expect(announceCount() == admitted)
|
||||
|
||||
// Simulated time is the only thing that opens the window. The first
|
||||
// advance drains any work already scheduled, so the second crosses
|
||||
// the forced minimum interval with nothing else due.
|
||||
mesh.advanceTime(by: 5)
|
||||
mesh.advanceTime(by: TransportConfig.bleForceAnnounceMinIntervalSeconds + 0.01)
|
||||
let beforeAdmitted = announceCount()
|
||||
mesh.forceAnnounce(from: 0)
|
||||
#expect(announceCount() == beforeAdmitted + 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -144,7 +193,8 @@ struct SimulatedMeshTests {
|
||||
|
||||
// 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.)
|
||||
// (Clear the throttle rather than advance time: this announce must
|
||||
// land without releasing any other scheduled work.)
|
||||
b.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.settleUntil {
|
||||
@ -302,7 +352,7 @@ struct SimulatedMeshTests {
|
||||
|
||||
// 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.)
|
||||
// (Reset the announce throttles so these actually send.)
|
||||
b.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
a.service._test_resetAnnounceThrottle()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user