Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion bitchat/Services/BLE/BLESubscriptionAnnounceLimiter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,14 @@ struct BLESubscriptionAnnounceLimiter {
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
)
// `lastAnnounceTime` deliberately keeps the time of the last *allowed*
// announce. It is the reference for both the backoff and
// `pruneStaleEntries`, so refreshing it on a rejection restarts the
// tracking window on every attempt — a central resubscribing faster
// than the maximum backoff could then never reach the window and
// stayed suppressed for as long as it kept trying.
states[centralID] = State(
lastAnnounceTime: now,
lastAnnounceTime: existing.lastAnnounceTime,
attemptCount: newAttemptCount,
currentBackoffSeconds: newBackoff
)
Expand Down
46 changes: 46 additions & 0 deletions bitchatTests/Services/BLESubscriptionAnnounceLimiterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,50 @@ struct BLESubscriptionAnnounceLimiterTests {
#expect(limiter.decision(for: freshCentralID, now: afterWindow) == .allowed)
#expect(limiter.trackedCentralCount == 1)
}

@Test("a central that keeps resubscribing recovers once the backoff elapses")
func flappingCentralRecoversAfterBackoff() {
// A legitimate central whose BLE link flaps resubscribes faster than the
// maximum backoff. Every rejection used to refresh `lastAnnounceTime`,
// which is the reference for both the backoff and `pruneStaleEntries`,
// so the elapsed time never grew: the central stayed suppressed for as
// long as it kept trying. It must instead be re-admitted once the
// capped backoff has passed since its last *allowed* announce.
var limiter = BLESubscriptionAnnounceLimiter()
let centralID = "central-flapping"
let start = Date(timeIntervalSince1970: 1_000)

#expect(limiter.decision(for: centralID, now: start) == .allowed)

let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
var admittedAt: Double?
for step in stride(from: 1.0, through: maxBackoff * 2, by: 1.0) {
if limiter.decision(for: centralID, now: start.addingTimeInterval(step)) == .allowed {
admittedAt = step
break
}
}

#expect(admittedAt != nil, "a central that keeps trying must eventually be re-admitted")
if let admittedAt {
#expect(admittedAt >= maxBackoff, "re-admitted early at +\(admittedAt)s")
}
}

@Test("a hammering central is still held off for the whole backoff")
func flappingCentralIsNotAdmittedEarly() {
// The counterpart: relaxing the reference timestamp must not let a
// hammering central back in before the capped backoff has elapsed.
var limiter = BLESubscriptionAnnounceLimiter()
let centralID = "central-flapping"
let start = Date(timeIntervalSince1970: 2_000)
let maxBackoff = TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds

#expect(limiter.decision(for: centralID, now: start) == .allowed)

for step in stride(from: 1.0, to: maxBackoff, by: 1.0) {
let decision = limiter.decision(for: centralID, now: start.addingTimeInterval(step))
#expect(decision != .allowed, "admitted early at +\(step)s")
}
}
}
Loading