mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 11:25:20 +00:00
Both failed on main-adjacent CI runs during this session's PRs. Neither was a product bug; both asserted things about real time that a loaded runner is under no obligation to honour. **NetworkReachabilityGateTests: a wall-clock upper bound.** test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit slept 500ms for real, then asserted total elapsed time was under 1.4s to prove a duplicate mid-window had not restarted the 1.0s debounce. One CI run took 3.75s. No wall-clock bound can separate "deadline preserved" from "runner is slow", because Task.sleep and the asyncAfter flush are both real time and neither is bounded above. The deadline property was already covered deterministically one level down: test_debounce_duplicateObservationsPreservePendingDeadline drives ReachabilityDebounce with injected timestamps and checks pendingRemaining directly. So the monitor test now asserts only what needs a real monitor — that a duplicate still yields exactly one committed false through the debounce — with an injected clock for the arithmetic and a generous liveness budget. Renamed to say what it actually checks. No coverage lost, and it runs in 0.14s instead of ~3.8s because the real sleep is gone. **NoiseEncryptionServiceTests: injected timeouts that also arm during setup.** #1483 diagnosed and fixed exactly this in the quarantine-restore test, but two sibling tests kept the shape. Their injected ordinaryResponderHandshakeTimeout (0.04 and 0.06) also arms during the establishSessions setup handshake, where bob is the responder — so a preempted runner fires it mid-setup, tears down the half-open responder, and message 3 gets answered as a fresh initiation. The CI failure named setup's own `#expect(finalMessage == nil)` seeing a 96-byte message 2, which is precisely the signature #1483 recorded. Raised both to 1.0s, matching #1483's remedy: the scenarios still need the responder timeout to fire, and it still does, just with room for setup to complete first. Thin 1-second waitUntil budgets in the file now use the shared TestConstants.longTimeout; every one of them backs a positive assertion, so waitUntil still returns the moment the condition holds and nothing gets slower in the passing case. No assertion weakened in either file. Verified 3x sequentially and 3x with all 18 cores saturated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
11 KiB
Swift
272 lines
11 KiB
Swift
import Combine
|
|
import XCTest
|
|
@testable import bitchat
|
|
|
|
/// Covers the reachability-gate decision logic (pure debounce) and the
|
|
/// `NetworkActivationService` wiring that suppresses Tor/relay startup when
|
|
/// there is provably no network.
|
|
@MainActor
|
|
final class NetworkReachabilityGateTests: XCTestCase {
|
|
|
|
// MARK: - Pure debounce logic
|
|
|
|
func test_debounce_satisfiedStaysReachable() {
|
|
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
let t0 = Date()
|
|
// An interface remains present: no change, no pending.
|
|
XCTAssertNil(d.observe(reachable: true, at: t0))
|
|
XCTAssertTrue(d.committed)
|
|
XCTAssertFalse(d.hasPendingChange)
|
|
}
|
|
|
|
func test_debounce_unsatisfiedSuppressesAfterInterval() {
|
|
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
let t0 = Date()
|
|
// Path drops: not committed immediately (within debounce window).
|
|
XCTAssertNil(d.observe(reachable: false, at: t0))
|
|
XCTAssertTrue(d.committed)
|
|
XCTAssertTrue(d.hasPendingChange)
|
|
// Still within window.
|
|
XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0)))
|
|
XCTAssertTrue(d.committed)
|
|
// Past the window: commit unreachable.
|
|
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false)
|
|
XCTAssertFalse(d.committed)
|
|
XCTAssertFalse(d.hasPendingChange)
|
|
}
|
|
|
|
func test_debounce_flapIsIgnored() {
|
|
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
let t0 = Date()
|
|
// Drop then recover well within the window — must never commit a change.
|
|
XCTAssertNil(d.observe(reachable: false, at: t0))
|
|
XCTAssertTrue(d.hasPendingChange)
|
|
XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5)))
|
|
XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop")
|
|
// A late flush after the original deadline is a no-op (nothing pending).
|
|
XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0)))
|
|
XCTAssertTrue(d.committed)
|
|
}
|
|
|
|
func test_debounce_recoverAfterOutageCommitsAfterInterval() {
|
|
var d = ReachabilityDebounce(interval: 2.5, initial: false)
|
|
let t0 = Date()
|
|
XCTAssertNil(d.observe(reachable: true, at: t0))
|
|
XCTAssertTrue(d.hasPendingChange)
|
|
XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true)
|
|
XCTAssertTrue(d.committed)
|
|
}
|
|
|
|
func test_debounce_duplicateObservationsPreservePendingDeadline() {
|
|
var d = ReachabilityDebounce(interval: 2.5, initial: true)
|
|
let t0 = Date()
|
|
XCTAssertNil(d.observe(reachable: false, at: t0))
|
|
// Duplicate unsatisfied updates mid-window keep the original deadline.
|
|
XCTAssertNil(d.observe(reachable: false, at: t0.addingTimeInterval(1.0)))
|
|
XCTAssertEqual(d.pendingRemaining(at: t0.addingTimeInterval(1.0)), 1.5)
|
|
// A duplicate arriving past the deadline commits immediately.
|
|
XCTAssertEqual(d.observe(reachable: false, at: t0.addingTimeInterval(2.5)), false)
|
|
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 }
|
|
)
|
|
var received: [Bool] = []
|
|
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
|
defer { cancellable.cancel() }
|
|
|
|
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)
|
|
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 }
|
|
XCTAssertTrue(committed)
|
|
XCTAssertEqual(received, [false])
|
|
}
|
|
|
|
// MARK: - Service gating
|
|
|
|
func test_start_whenUnreachable_suppressesTorAndRelays() {
|
|
let ctx = makeService(permission: .authorized, reachable: false)
|
|
ctx.service.start()
|
|
|
|
XCTAssertFalse(ctx.service.activationAllowed)
|
|
XCTAssertFalse(ctx.service.isNetworkReachable)
|
|
XCTAssertTrue(ctx.reachability.startCalled)
|
|
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0)
|
|
XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false])
|
|
XCTAssertEqual(ctx.relayController.connectCallCount, 0)
|
|
XCTAssertEqual(ctx.relayController.disconnectCallCount, 1)
|
|
}
|
|
|
|
func test_start_whenReachable_allowsTorAndRelays() {
|
|
let ctx = makeService(permission: .authorized, reachable: true)
|
|
ctx.service.start()
|
|
|
|
XCTAssertTrue(ctx.service.activationAllowed)
|
|
XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1)
|
|
XCTAssertEqual(ctx.relayController.connectCallCount, 1)
|
|
}
|
|
|
|
func test_reachabilityRecovery_resumesTorAndRelays() async {
|
|
let ctx = makeService(permission: .authorized, reachable: false)
|
|
ctx.service.start()
|
|
XCTAssertFalse(ctx.service.activationAllowed)
|
|
|
|
ctx.reachability.set(true)
|
|
let resumed = await waitUntil { ctx.service.activationAllowed }
|
|
XCTAssertTrue(resumed)
|
|
XCTAssertTrue(ctx.service.isNetworkReachable)
|
|
XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1)
|
|
XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1)
|
|
}
|
|
|
|
func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async {
|
|
let ctx = makeService(permission: .authorized, reachable: true)
|
|
ctx.service.start()
|
|
XCTAssertTrue(ctx.service.activationAllowed)
|
|
let disconnectsBefore = ctx.relayController.disconnectCallCount
|
|
|
|
ctx.reachability.set(false)
|
|
let suppressed = await waitUntil { !ctx.service.activationAllowed }
|
|
XCTAssertTrue(suppressed)
|
|
XCTAssertFalse(ctx.service.isNetworkReachable)
|
|
XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore)
|
|
XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false))
|
|
XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1)
|
|
}
|
|
|
|
// MARK: - Harness
|
|
|
|
private func makeService(
|
|
permission: LocationChannelManager.PermissionState,
|
|
reachable: Bool
|
|
) -> Context {
|
|
let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)"
|
|
let storage = UserDefaults(suiteName: suiteName)!
|
|
storage.removePersistentDomain(forName: suiteName)
|
|
|
|
let permissionSubject = CurrentValueSubject<LocationChannelManager.PermissionState, Never>(permission)
|
|
let favoritesSubject = CurrentValueSubject<Set<Data>, Never>([])
|
|
let reachability = ControllableReachabilityMonitor(initial: reachable)
|
|
let torController = GateMockTorController()
|
|
let relayController = GateMockRelayController()
|
|
let proxyController = GateMockProxyController()
|
|
let service = NetworkActivationService(
|
|
storage: storage,
|
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
|
permissionProvider: { permissionSubject.value },
|
|
mutualFavoritesProvider: { favoritesSubject.value },
|
|
reachabilityMonitor: reachability,
|
|
torController: torController,
|
|
relayController: relayController,
|
|
proxyController: proxyController,
|
|
notificationCenter: NotificationCenter()
|
|
)
|
|
return Context(
|
|
service: service,
|
|
reachability: reachability,
|
|
torController: torController,
|
|
relayController: relayController
|
|
)
|
|
}
|
|
|
|
private func waitUntil(
|
|
timeout: TimeInterval = 1.0,
|
|
condition: @escaping @MainActor () -> Bool
|
|
) async -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if condition() { return true }
|
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
}
|
|
return condition()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct Context {
|
|
let service: NetworkActivationService
|
|
let reachability: ControllableReachabilityMonitor
|
|
let torController: GateMockTorController
|
|
let relayController: GateMockRelayController
|
|
}
|
|
|
|
@MainActor
|
|
private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring {
|
|
private let subject: CurrentValueSubject<Bool, Never>
|
|
private(set) var startCalled = false
|
|
|
|
init(initial: Bool) {
|
|
subject = CurrentValueSubject(initial)
|
|
}
|
|
|
|
var isReachable: Bool { subject.value }
|
|
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
|
}
|
|
func start() { startCalled = true }
|
|
func stop() { startCalled = false }
|
|
func set(_ reachable: Bool) { subject.send(reachable) }
|
|
}
|
|
|
|
@MainActor
|
|
private final class GateMockTorController: NetworkActivationTorControlling {
|
|
private(set) var autoStartAllowedValues: [Bool] = []
|
|
private(set) var startIfNeededCallCount = 0
|
|
private(set) var shutdownCompletelyCallCount = 0
|
|
func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) }
|
|
func startIfNeeded() { startIfNeededCallCount += 1 }
|
|
func shutdownCompletely() { shutdownCompletelyCallCount += 1 }
|
|
}
|
|
|
|
@MainActor
|
|
private final class GateMockRelayController: NetworkActivationRelayControlling {
|
|
private(set) var connectCallCount = 0
|
|
private(set) var disconnectCallCount = 0
|
|
func connect() { connectCallCount += 1 }
|
|
func disconnect() { disconnectCallCount += 1 }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|