diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift index e3c916af..0340b9f3 100644 --- a/bitchatTests/Services/NetworkReachabilityGateTests.swift +++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift @@ -69,25 +69,45 @@ final class NetworkReachabilityGateTests: XCTestCase { XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5))) } - func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async { - let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0) + /// 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() } - let start = Date() monitor.ingest(reachable: false) - 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. + // 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) - let committed = await waitUntil(timeout: 2.0) { !received.isEmpty } + // 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]) - // 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 @@ -239,3 +259,13 @@ 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 + } +} diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 7409cbba..55229501 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests { ) let retried = await TestHelpers.waitUntil( { recorder.messages.count == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retried) let retryExpired = await TestHelpers.waitUntil( { !service.hasSession(with: peerID) }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retryExpired) #expect(recorder.timeoutCount == 1) @@ -710,7 +710,12 @@ struct NoiseEncryptionServiceTests { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService( keychain: MockKeychain(), - ordinaryResponderHandshakeTimeout: 0.06, + // 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, ordinaryReconnectRollbackCooldown: 0.3 ) let mallory = NoiseEncryptionService(keychain: MockKeychain()) @@ -741,12 +746,12 @@ struct NoiseEncryptionServiceTests { let restored = await TestHelpers.waitUntil( { bob.hasEstablishedSession(with: alicePeerID) }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(restored) let callbackArrived = await TestHelpers.waitUntil( { recovery.timeoutCount == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(callbackArrived) @@ -780,7 +785,13 @@ struct NoiseEncryptionServiceTests { let bob = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: 0.04, - ordinaryResponderHandshakeTimeout: 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 ) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) @@ -814,12 +825,12 @@ struct NoiseEncryptionServiceTests { // initiates one bounded convergence retry; drop that message 1 too. let retryPrepared = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retryPrepared) let retryExpired = await TestHelpers.waitUntil( { !bob.hasSession(with: alicePeerID) }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(retryExpired) #expect(recovery.timeoutCount == 1) @@ -1026,7 +1037,7 @@ struct NoiseEncryptionServiceTests { let requested = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, - timeout: 1 + timeout: TestConstants.longTimeout ) #expect(requested) let retryMessage1 = try #require(recovery.messages.first)