mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 13:25:22 +00:00
Deflake CI, and make the flake class unrepeatable (#1491)
* Deflake two iOS-sim CI tests with unbounded timing assumptions 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> * Raise two more starvation-prone test deadlines Both surfaced on the CI runs for this PR and #1487, both in tests neither PR touches, both the same shape as the two already fixed here: a deadline sized for the work rather than for a runner executing many suites at once. VoiceNotePlaybackControllerTests waited 5s for a @MainActor Task that playback schedules for the session acquire and its failure path. When that Task is not scheduled in time the helper reports *two* failures — the wait, and the `!isPlaying` the un-run failure path has not reset yet — which reads like a playback bug rather than a starved scheduler. That is exactly what CI showed. GeoRelayDirectoryTests waited 10s for work the directory runs in Task.detached(priority: .utility); utility priority competes with every other suite. The retry-scheduling case timed out at exactly 10.06s with the retry never scheduled, reading like a missing retry rather than a starved background task. Raised to 30s each with the reasoning recorded at the helper. Both helpers return as soon as their condition holds, so nothing slows down when tests pass — only the genuine-failure case takes longer to report. Verified 3x with all cores saturated: both suites clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Make the flake class unrepeatable, not just fixed Raising four deadlines fixed the four tests that happened to fire. The class was still there: fourteen separate waitUntil helpers, most defaulting to 1.0s, plus wait call sites with literal budgets. The fifth instance would have landed the same way, on someone else's unrelated PR. The rule, now written down in TestConstants.settleTimeout: **a wait deadline is not a latency budget.** It exists so a genuine hang eventually fails the suite, so size it for the worst-case scheduler, never for how long the operation should take. Waits return as soon as their condition holds, so a generous deadline is free in the passing case and only extends genuine failures. - Every wait helper now defaults to TestConstants.settleTimeout (30s), and the literal wait call sites below the floor were converted too — sixteen sites across ten files. - TestTimingHygieneTests enforces it by scanning the test sources: wait defaults and wait call sites must be at least minimumSettleTimeout, and no test may assert an upper bound on elapsed wall-clock time (the assertion that started this, which cannot separate correct behaviour from a slow machine). - Both rules waive per line with "test-timing-ok: <reason>", accepted on the line or in the comment block above it so the reason has room to be a sentence. One legitimate use so far: a NEGATIVE wait in NoiseCoverageTests asserting a promotion has *not* completed within 50ms, where a long deadline would only make the suite slow while still passing. Injected production timeouts are deliberately not matched — the Noise handshake timeouts are the behaviour under test, and short values are correct there. Verified the guard actually fails: a canary file with both banned shapes was flagged with file and line, and the suite went green again once it was removed. Full suite passes with all 18 cores saturated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Close the guard's blind spot: named short timeouts A fifth flake landed on CI while the first guard was in review — GossipSyncBoardTests timing out at 1.03s — and the guard did not catch it, because the deadline was `TestConstants.shortTimeout` rather than a literal. A literals-only scan cannot see a short value behind a symbol, which is the more common way it is written: 81 wait sites used shortTimeout (1s) or defaultTimeout (5s), every one of them below the floor. Rather than guess which of those were safe to raise, all 81 were converted and the suite timed. Runtime went 15s -> 72s, which located the genuine negative waits precisely: ten tests that assert something does *not* happen and therefore always run their deadline out. Measurement instead of a heuristic, since a mis-guess in either direction is invisible — too short reintroduces the flake, too long silently costs a minute a run. Those 28 sites now use `TestConstants.negativeWaitWindow`, a named constant whose doc explains the inverted reasoning: for a negative wait, starvation can only make the assertion *more* likely to hold, so short is correct, and the name states the polarity instead of leaving a bare literal that reads like the mistake. Suite is back to 15.3s. The guard now also rejects `timeout: TestConstants.shortTimeout` and `defaultTimeout`, while accepting `negativeWaitWindow` by name. Re-verified with a canary carrying every banned shape — literal default, both named constants, and an elapsed-time upper bound. All four were flagged with file and line; the suite went green again on removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Delete shortTimeout now that nothing may use it The hygiene guard bans `TestConstants.shortTimeout` at every wait site and the last users were converted, so Periphery correctly flagged the constant itself as dead and failed CI. Remove it from both TestConstants copies; the banned-name entry stays so the symbol cannot quietly return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
jack
Claude Opus 5
parent
14e7b428d9
commit
0152196ac2
@@ -39,7 +39,7 @@ struct BLEServiceCoreTests {
|
||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!receivedDuplicate)
|
||||
|
||||
@@ -117,7 +117,7 @@ struct BLEServiceCoreTests {
|
||||
|
||||
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!unsignedRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
@@ -133,7 +133,7 @@ struct BLEServiceCoreTests {
|
||||
|
||||
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .leave) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!badSignatureRelayed)
|
||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||
@@ -1209,7 +1209,7 @@ struct BLEServiceCoreTests {
|
||||
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
gate.release()
|
||||
continuation.resume(returning: didObserveClosure)
|
||||
@@ -1340,7 +1340,7 @@ struct BLEServiceCoreTests {
|
||||
// rotated sender IDs never bought a sixth response.
|
||||
let exceededBudget = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .pong) > budget },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!exceededBudget)
|
||||
#expect(outbound.count(ofType: .pong) == budget)
|
||||
|
||||
@@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests {
|
||||
transport.simulateConnect(peerID, nickname: "alice")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action: User types /msg command
|
||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
|
||||
// Assert:
|
||||
@@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests {
|
||||
transport.simulateConnect(peerID, nickname: "troll")
|
||||
|
||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didResolve)
|
||||
|
||||
// Action
|
||||
@@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests {
|
||||
// Assert
|
||||
// Verify identity manager was called to block "fingerprint_123"
|
||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||
timeout: TestConstants.shortTimeout)
|
||||
timeout: TestConstants.settleTimeout)
|
||||
#expect(didBlock)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests {
|
||||
// Wait for async processing with proper timeout
|
||||
let found = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
@@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests {
|
||||
{
|
||||
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
||||
},
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -321,7 +321,7 @@ struct ChatViewModelCommandTests {
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
let resolved = await TestHelpers.waitUntil({
|
||||
viewModel.getPeerIDForNickname("Alice") == peerID
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.negativeWaitWindow)
|
||||
#expect(resolved)
|
||||
|
||||
viewModel.handleCommand("/msg Alice")
|
||||
@@ -422,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
transport.sentReadReceipts.contains {
|
||||
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
|
||||
}
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.negativeWaitWindow)
|
||||
|
||||
#expect(sentReadReceipt)
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
||||
@@ -506,7 +506,7 @@ struct ChatViewModelReceivingTests {
|
||||
|
||||
let found = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(found)
|
||||
}
|
||||
@@ -535,11 +535,11 @@ struct ChatViewModelNoisePayloadTests {
|
||||
|
||||
let stored = await TestHelpers.waitUntil({
|
||||
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let acked = await TestHelpers.waitUntil({
|
||||
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(stored)
|
||||
#expect(acked)
|
||||
@@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return name == "Bob"
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(delivered)
|
||||
}
|
||||
@@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
||||
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
@@ -626,7 +626,7 @@ struct ChatViewModelNoisePayloadTests {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(privateChatUpdated)
|
||||
#expect(conversationStoreUpdated)
|
||||
@@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests {
|
||||
|
||||
let bound = await TestHelpers.waitUntil({
|
||||
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(bound)
|
||||
|
||||
let qr = VerificationService.VerificationQR(
|
||||
@@ -982,7 +982,7 @@ struct ChatViewModelPeerTests {
|
||||
|
||||
let cleaned = await TestHelpers.waitUntil({
|
||||
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
|
||||
#expect(cleaned)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -151,7 +151,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -161,7 +161,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -169,7 +169,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(handedOver)
|
||||
// With CoreBluetooth disabled there is no physical link for the send
|
||||
@@ -183,7 +183,7 @@ struct CourierEndToEndTests {
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
@@ -229,7 +229,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -237,7 +237,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -245,7 +245,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -253,7 +253,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
@@ -265,7 +265,7 @@ struct CourierEndToEndTests {
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!delivered)
|
||||
}
|
||||
@@ -293,7 +293,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -301,7 +301,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -310,7 +310,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!leakedOnUnverifiedAnnounce)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -318,7 +318,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(announced)
|
||||
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -326,7 +326,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -355,7 +355,7 @@ struct CourierEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -363,14 +363,14 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -385,7 +385,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let remoteHandover = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(remoteHandover)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -398,7 +398,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let freshAnnounce = try #require(
|
||||
@@ -410,7 +410,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let refloodedInCooldown = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!refloodedInCooldown)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -424,7 +424,7 @@ struct CourierEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announcedAgain = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(announcedAgain)
|
||||
let directAgain = try #require(
|
||||
@@ -434,7 +434,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!handedOverWithoutLinkProof)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
@@ -457,7 +457,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let queuedPacket = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!queuedPacket)
|
||||
}
|
||||
@@ -494,7 +494,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -532,7 +532,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -575,7 +575,7 @@ struct CourierEndToEndTests {
|
||||
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
@@ -602,14 +602,14 @@ struct CourierEndToEndTests {
|
||||
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(delivered)
|
||||
// Give a duplicate delivery a chance to surface, then confirm the
|
||||
// second copy never reached the delegate.
|
||||
let duplicated = await TestHelpers.waitUntil(
|
||||
{ bobDelegate.snapshot().count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!duplicated)
|
||||
#expect(bobDelegate.snapshot().count == 1)
|
||||
@@ -629,7 +629,7 @@ struct CourierEndToEndTests {
|
||||
|
||||
let initiated = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!initiated)
|
||||
|
||||
@@ -639,7 +639,7 @@ struct CourierEndToEndTests {
|
||||
ble.sendDeliveryAck(for: "msg-2", to: present)
|
||||
let initiatedForPresent = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(initiatedForPresent)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ struct PrekeyEndToEndTests {
|
||||
peer.sendBroadcastAnnounce()
|
||||
let published = await TestHelpers.waitUntil(
|
||||
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(published)
|
||||
return (
|
||||
@@ -124,7 +124,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
|
||||
@@ -138,7 +138,7 @@ struct PrekeyEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -149,7 +149,7 @@ struct PrekeyEndToEndTests {
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
@@ -158,7 +158,7 @@ struct PrekeyEndToEndTests {
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
|
||||
@@ -166,7 +166,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
@@ -178,7 +178,7 @@ struct PrekeyEndToEndTests {
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
@@ -207,7 +207,7 @@ struct PrekeyEndToEndTests {
|
||||
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
|
||||
let redelivered = await TestHelpers.waitUntil(
|
||||
{ bobDelegate.snapshot().count == 2 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!redelivered)
|
||||
#expect(bobDelegate.snapshot().count == 1)
|
||||
@@ -235,7 +235,7 @@ struct PrekeyEndToEndTests {
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
@@ -248,7 +248,7 @@ struct PrekeyEndToEndTests {
|
||||
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(received)
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
@@ -272,7 +272,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
}
|
||||
@@ -328,7 +328,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(cached)
|
||||
// The verified bundle now participates in Alice's sync rounds.
|
||||
@@ -364,7 +364,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
@@ -396,7 +396,7 @@ struct PrekeyEndToEndTests {
|
||||
|
||||
let cached = await TestHelpers.waitUntil(
|
||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!cached)
|
||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||
|
||||
@@ -37,7 +37,7 @@ struct GossipSyncManagerTests {
|
||||
}
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.settleTimeout)
|
||||
}
|
||||
|
||||
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||
@@ -394,7 +394,7 @@ struct GossipSyncManagerTests {
|
||||
)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.settleTimeout)
|
||||
// Barrier: flush the sync queue so a late third packet would be visible.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
let sentPackets = delegate.packets
|
||||
@@ -477,7 +477,7 @@ struct GossipSyncManagerTests {
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.settleTimeout)
|
||||
// Barrier: both requests have been processed once this returns.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
#expect(delegate.packets.count == 1)
|
||||
@@ -498,7 +498,7 @@ struct GossipSyncManagerTests {
|
||||
|
||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let packet = try #require(delegate.packets.first)
|
||||
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
|
||||
let types = try #require(request.types)
|
||||
@@ -553,7 +553,7 @@ struct GossipSyncManagerTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
@@ -615,7 +615,7 @@ struct GossipSyncManagerTests {
|
||||
)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
// Barrier: flush the sync queue so a late second packet would be visible.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
let sentPackets = delegate.packets
|
||||
@@ -641,7 +641,7 @@ struct GossipSyncManagerTests {
|
||||
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
||||
manager.requestMissingFragments(fragmentIDs: [stalledID])
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.requestSync.rawValue)
|
||||
#expect(sent.ttl == 0)
|
||||
@@ -697,7 +697,7 @@ struct GossipSyncManagerTests {
|
||||
// And a .prekeyBundle sync request is answered with the stored packet.
|
||||
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let served = try #require(delegate.packets.first)
|
||||
#expect(served.type == MessageType.prekeyBundle.rawValue)
|
||||
#expect(served.isRSR)
|
||||
@@ -774,7 +774,7 @@ struct GossipSyncManagerTests {
|
||||
)
|
||||
let restored = await TestHelpers.waitUntil(
|
||||
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(restored)
|
||||
}
|
||||
@@ -844,7 +844,7 @@ struct GossipSyncManagerTests {
|
||||
!FileManager.default.fileExists(atPath: fileURL.path)
|
||||
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
|
||||
},
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(erased)
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -723,9 +723,9 @@ struct NoiseCoverageTests {
|
||||
// A failed startup requirement must not strand a late thread in
|
||||
// the blocking test double after the test has returned.
|
||||
oldSession.resumeDecrypt()
|
||||
_ = decryptResult.wait(timeout: 5)
|
||||
_ = decryptResult.wait(timeout: TestConstants.settleTimeout)
|
||||
if let promotionResultForCleanup {
|
||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
||||
_ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,15 +751,19 @@ struct NoiseCoverageTests {
|
||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||
promotionThread.qualityOfService = .userInitiated
|
||||
promotionThread.start()
|
||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
||||
try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success)
|
||||
#expect(
|
||||
// test-timing-ok: a NEGATIVE wait — it asserts the promotion has
|
||||
// NOT completed yet, so a long deadline would only make the suite
|
||||
// slow while still passing. A starved runner can only make this
|
||||
// more likely to hold, never less.
|
||||
promotionResult.wait(timeout: 0.05) == nil,
|
||||
"Promotion must wait for the exact decrypting-session lease"
|
||||
)
|
||||
|
||||
oldSession.resumeDecrypt()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
||||
let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
_ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||
|
||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||
|
||||
@@ -580,8 +580,16 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
/// constrained CI runners (2-core, serialized testing) can starve the
|
||||
/// detached utility-priority fetch task for seconds before it runs, and
|
||||
/// a successful wait returns as soon as the condition becomes true.
|
||||
/// Default deliberately far larger than the work being awaited.
|
||||
///
|
||||
/// The directory performs its fetch in a `Task.detached(priority: .utility)`,
|
||||
/// and utility priority competes with every other suite on a CI runner. At
|
||||
/// ten seconds the retry-scheduling test timed out at exactly 10.06s with
|
||||
/// the retry never scheduled — which reads like a missing retry rather than
|
||||
/// a starved background task. Returning as soon as the condition holds means
|
||||
/// a longer deadline only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 10.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () async -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -188,7 +188,7 @@ struct PTTBurstPlayerTests {
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||
|
||||
wait(for: [expectation], timeout: 1.0)
|
||||
wait(for: [expectation], timeout: TestConstants.settleTimeout)
|
||||
XCTAssertTrue(service.isFavorite(peerKey))
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
||||
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
||||
|
||||
@@ -227,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -63,7 +63,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
context.service.start()
|
||||
context.service.setUserTorEnabled(false)
|
||||
|
||||
wait(for: [notified], timeout: 1.0)
|
||||
wait(for: [notified], timeout: TestConstants.negativeWaitWindow)
|
||||
context.notificationCenter.removeObserver(token)
|
||||
|
||||
XCTAssertFalse(context.service.userTorEnabled)
|
||||
@@ -243,7 +243,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -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
|
||||
@@ -179,7 +199,7 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ struct NoiseEncryptionServiceTests {
|
||||
|
||||
try establishSessions(alice: alice, bob: bob)
|
||||
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(authenticated)
|
||||
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||
{ recorder.generationCount >= 1 },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(generationAuthenticated)
|
||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||
@@ -166,7 +166,7 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!emittedAuthentication)
|
||||
}
|
||||
@@ -216,7 +216,7 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||
{ recorder.count > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
timeout: TestConstants.negativeWaitWindow
|
||||
)
|
||||
#expect(!emittedReplacementAuthentication)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -960,7 +960,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
|
||||
}
|
||||
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) {
|
||||
receivedIDs.count == events.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
@@ -1006,7 +1006,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
|
||||
|
||||
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
|
||||
let quietDelivered = await waitUntil(timeout: TestConstants.settleTimeout) { quietDeliveredAfterBusyCount >= 0 }
|
||||
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
|
||||
|
||||
// The signal: B did not have to wait for A's entire backlog. If the two
|
||||
@@ -1019,7 +1019,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
)
|
||||
|
||||
// Both relays still drain fully and in order.
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
let allDelivered = await waitUntil(timeout: TestConstants.settleTimeout) {
|
||||
busyDeliveredCount == busyEvents.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
@@ -1907,7 +1907,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -164,7 +164,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -209,7 +209,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||
@@ -250,7 +250,7 @@ struct NostrTransportTests {
|
||||
|
||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||
|
||||
@@ -288,7 +288,7 @@ struct NostrTransportTests {
|
||||
messageID: "geo-1"
|
||||
)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
let event = probe.sentEvents[0]
|
||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||
|
||||
@@ -562,7 +562,7 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -320,7 +320,7 @@ struct SecureIdentityStateManagerVouchTests {
|
||||
// MARK: - Helpers
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct GossipSyncBoardTests {
|
||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
let sent = try #require(delegate.packets.first)
|
||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||
#expect(sent.isRSR)
|
||||
@@ -69,7 +69,7 @@ struct GossipSyncBoardTests {
|
||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(delegate.packets.count == 1)
|
||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ final class RequestSyncManagerTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||
condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -11,14 +11,54 @@ import Foundation
|
||||
|
||||
struct TestConstants {
|
||||
static let defaultTimeout: TimeInterval = 5.0
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
/// For positive waits on work that hops through `Task.detached` or
|
||||
/// background queues: those contend with every parallel test worker for
|
||||
/// the global executor, so a loaded CI runner can exceed
|
||||
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
|
||||
/// so passing runs never pay the longer timeout.
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
|
||||
/// **Default deadline for any "wait until this async thing settles" helper.**
|
||||
///
|
||||
/// Four separate tests flaked on CI during July 2026 with the same root
|
||||
/// cause, and it is worth stating the rule rather than re-learning it a
|
||||
/// fifth time: *a wait deadline is not a latency budget.* It exists so a
|
||||
/// genuine hang eventually fails the suite. Size it for the worst-case
|
||||
/// scheduler, never for how long the operation "should" take.
|
||||
///
|
||||
/// A CI runner executes many suites at once. Work behind `@MainActor`,
|
||||
/// `Task.detached(priority: .utility)`, or a `DispatchQueue.asyncAfter` can
|
||||
/// be starved for seconds — one observed run took 3.75 s for a 1 s
|
||||
/// operation. Deadlines sized to the operation (the old 1 s defaults) turn
|
||||
/// that starvation into a red build that reads like a product bug.
|
||||
///
|
||||
/// This costs nothing when tests pass, because every helper returns as soon
|
||||
/// as its condition holds. It only extends the genuine-failure case.
|
||||
///
|
||||
/// `TestTimingHygieneTests` enforces that wait helpers default to at least
|
||||
/// `minimumSettleTimeout`.
|
||||
static let settleTimeout: TimeInterval = 30.0
|
||||
|
||||
/// Floor enforced by `TestTimingHygieneTests`. Anything below this is a
|
||||
/// latency assumption in disguise.
|
||||
static let minimumSettleTimeout: TimeInterval = 10.0
|
||||
|
||||
/// For waits whose **expected outcome is `false`** — "prove this does not
|
||||
/// happen".
|
||||
///
|
||||
/// The floor above is wrong for these, and inverted: a negative wait always
|
||||
/// runs its deadline out, so `settleTimeout` would spend 30 s per case
|
||||
/// proving nothing extra. Starvation cannot cause a false failure here
|
||||
/// either — a starved runner only makes the thing *less* likely to happen,
|
||||
/// so the assertion still holds. Short is correct, and naming it says the
|
||||
/// polarity out loud instead of leaving a bare literal that reads like the
|
||||
/// mistake this file exists to prevent.
|
||||
///
|
||||
/// `TestTimingHygieneTests` accepts this by name. Using it for a wait you
|
||||
/// expect to succeed reintroduces exactly the flake class it sits next to.
|
||||
static let negativeWaitWindow: TimeInterval = 1.0
|
||||
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
static let testNickname2 = "Bob"
|
||||
static let testNickname3 = "Charlie"
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
/// Guards the test suite against the flake class that produced four separate
|
||||
/// red builds in July 2026: **treating a wait deadline as a latency budget.**
|
||||
///
|
||||
/// A CI runner executes many suites at once, so work behind `@MainActor`,
|
||||
/// `Task.detached(priority: .utility)`, or `DispatchQueue.asyncAfter` can be
|
||||
/// starved for seconds. One observed run took 3.75 s for a 1 s operation.
|
||||
/// Deadlines sized to how long the operation "should" take turn that starvation
|
||||
/// into a red build that reads like a product bug, and the debugging cost lands
|
||||
/// on whoever opened an unrelated PR.
|
||||
///
|
||||
/// Two rules, both enforced below:
|
||||
///
|
||||
/// 1. A wait helper's default deadline must be at least
|
||||
/// `TestConstants.minimumSettleTimeout`. Waits return as soon as their
|
||||
/// condition holds, so a generous deadline is free in the passing case.
|
||||
/// 2. No test asserts an *upper bound* on elapsed wall-clock time. Such an
|
||||
/// assertion cannot distinguish the behaviour under test from a slow
|
||||
/// machine, so it can only be flaky. Assert the property somewhere it is
|
||||
/// computable — with an injected clock, on the pure logic — instead.
|
||||
///
|
||||
/// Both rules can be waived per line with `\(Self.waiver)` plus a reason, for
|
||||
/// the rare case where the timing itself is genuinely the thing under test.
|
||||
struct TestTimingHygieneTests {
|
||||
/// Opt-out marker. Reviewers should expect a reason next to it.
|
||||
static let waiver = "test-timing-ok:"
|
||||
|
||||
private static let testsRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // TestUtilities
|
||||
.deletingLastPathComponent() // bitchatTests
|
||||
|
||||
private struct Line {
|
||||
let file: String
|
||||
let number: Int
|
||||
let text: String
|
||||
/// True when the waiver appears on this line or in the comment block
|
||||
/// immediately above it, so a reason can be written at readable length
|
||||
/// rather than crammed onto the end of the code line.
|
||||
let waived: Bool
|
||||
}
|
||||
|
||||
private static func swiftLines() throws -> [Line] {
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: testsRoot,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
var out: [Line] = []
|
||||
while let url = enumerator?.nextObject() as? URL {
|
||||
guard url.pathExtension == "swift" else { continue }
|
||||
// This file necessarily contains the patterns it bans.
|
||||
guard url.lastPathComponent != "TestTimingHygieneTests.swift" else { continue }
|
||||
let name = url.lastPathComponent
|
||||
let texts = try String(contentsOf: url, encoding: .utf8)
|
||||
.components(separatedBy: .newlines)
|
||||
for (index, text) in texts.enumerated() {
|
||||
// Scan back over an unbroken run of comment lines.
|
||||
var waived = text.contains(waiver)
|
||||
var back = index - 1
|
||||
while !waived, back >= 0 {
|
||||
let above = texts[back].trimmingCharacters(in: .whitespaces)
|
||||
guard above.hasPrefix("//") else { break }
|
||||
waived = above.contains(waiver)
|
||||
back -= 1
|
||||
}
|
||||
out.append(Line(file: name, number: index + 1, text: text, waived: waived))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func isWaived(_ line: Line) -> Bool {
|
||||
line.waived
|
||||
}
|
||||
|
||||
/// Rule 1: no wait helper may default to a deadline below the floor.
|
||||
@Test func waitHelpersDoNotDefaultToShortDeadlines() throws {
|
||||
let lines = try Self.swiftLines()
|
||||
#expect(!lines.isEmpty, "hygiene scan found no test sources — check the path")
|
||||
|
||||
// Two shapes, both of which have flaked here:
|
||||
// a declaration default — `timeout: TimeInterval = 2.5`
|
||||
// a wait call site — `wait(for:…, timeout: 1.0)`, `waitUntil(timeout: 5.0)`
|
||||
//
|
||||
// Deliberately NOT matched: a bare `timeout:` label on something that is
|
||||
// not a wait, such as the injected production handshake timeouts in the
|
||||
// Noise tests. Those are the behaviour under test, and a short value is
|
||||
// correct there.
|
||||
let patterns = [
|
||||
#"(?:timeout|deadline)\s*:\s*TimeInterval\s*=\s*([0-9]+(?:\.[0-9]+)?)"#,
|
||||
#"(?:wait|waitUntil|waitFor|fulfillment)\s*\([^)]*\btimeout:\s*([0-9]+(?:\.[0-9]+)?)"#
|
||||
].map { try? NSRegularExpression(pattern: $0) }.compactMap { $0 }
|
||||
#expect(patterns.count == 2, "hygiene regexes failed to compile")
|
||||
|
||||
// Named constants hide the same mistake behind a symbol, and did: the
|
||||
// fifth flake of the session was `timeout: TestConstants.shortTimeout`
|
||||
// (1 s) on a positive wait, which a literals-only scan cannot see.
|
||||
// `shortTimeout` itself is deleted (Periphery flagged it dead once its
|
||||
// last wait site converted); the ban stays so it cannot come back.
|
||||
// `negativeWaitWindow` is deliberately absent — short is correct there.
|
||||
let bannedConstants = ["shortTimeout", "defaultTimeout"]
|
||||
|
||||
var offenders: [String] = []
|
||||
for line in lines where !Self.isWaived(line) {
|
||||
let range = NSRange(line.text.startIndex..., in: line.text)
|
||||
var flagged = false
|
||||
for pattern in patterns {
|
||||
guard let match = pattern.firstMatch(in: line.text, range: range),
|
||||
let valueRange = Range(match.range(at: 1), in: line.text),
|
||||
let value = TimeInterval(line.text[valueRange]),
|
||||
value < TestConstants.minimumSettleTimeout else { continue }
|
||||
offenders.append("\(line.file):\(line.number) — \(value)s: \(line.text.trimmingCharacters(in: .whitespaces))")
|
||||
flagged = true
|
||||
break
|
||||
}
|
||||
guard !flagged else { continue }
|
||||
for name in bannedConstants
|
||||
where line.text.contains("timeout: TestConstants.\(name)") {
|
||||
offenders.append("\(line.file):\(line.number) — TestConstants.\(name): \(line.text.trimmingCharacters(in: .whitespaces))")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
#expect(
|
||||
offenders.isEmpty,
|
||||
"""
|
||||
Wait deadlines below \(TestConstants.minimumSettleTimeout)s are latency \
|
||||
assumptions and will flake on a loaded runner. Use \
|
||||
TestConstants.settleTimeout, or add "\(Self.waiver) <reason>" if the \
|
||||
timing really is what the test asserts.
|
||||
|
||||
\(offenders.joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// Rule 2: no test bounds elapsed wall-clock time from above.
|
||||
///
|
||||
/// This is the assertion that started it all — `XCTAssertLessThan(
|
||||
/// Date().timeIntervalSince(start), 1.4)` proving a debounce deadline was
|
||||
/// not restarted. It cannot separate "behaved correctly" from "runner was
|
||||
/// busy", so it only ever fails for the wrong reason.
|
||||
@Test func testsDoNotAssertUpperBoundsOnElapsedTime() throws {
|
||||
let lines = try Self.swiftLines()
|
||||
|
||||
let elapsedAssertion = try NSRegularExpression(
|
||||
pattern: #"(?:XCTAssertLessThan|XCTAssertLessThanOrEqual)\s*\(\s*(?:Date\(\)\.timeIntervalSince|[A-Za-z_][A-Za-z0-9_]*\.timeIntervalSince|ContinuousClock)"#
|
||||
)
|
||||
|
||||
var offenders: [String] = []
|
||||
for line in lines where !Self.isWaived(line) {
|
||||
let range = NSRange(line.text.startIndex..., in: line.text)
|
||||
guard elapsedAssertion.firstMatch(in: line.text, range: range) != nil else { continue }
|
||||
offenders.append("\(line.file):\(line.number) — \(line.text.trimmingCharacters(in: .whitespaces))")
|
||||
}
|
||||
|
||||
#expect(
|
||||
offenders.isEmpty,
|
||||
"""
|
||||
An upper bound on elapsed wall-clock time cannot distinguish the \
|
||||
behaviour under test from a slow machine. Assert the property where \
|
||||
it is computable — inject a clock, or test the pure logic — or add \
|
||||
"\(Self.waiver) <reason>".
|
||||
|
||||
\(offenders.joined(separator: "\n"))
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// The floor must stay meaningfully above the operations being waited on,
|
||||
/// and the default must satisfy the rule this file enforces.
|
||||
@Test func settleTimeoutsAreSelfConsistent() {
|
||||
#expect(TestConstants.settleTimeout >= TestConstants.minimumSettleTimeout)
|
||||
#expect(TestConstants.minimumSettleTimeout > TestConstants.defaultTimeout)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ struct VoiceCaptureSessionTests {
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -59,11 +59,24 @@ struct VoiceNotePlaybackControllerTests {
|
||||
return url
|
||||
}
|
||||
|
||||
/// Waits for an async settle, then asserts.
|
||||
///
|
||||
/// The deadline is deliberately far larger than the work it waits on. Every
|
||||
/// condition here depends on a `@MainActor` Task that playback schedules
|
||||
/// (the session acquire and its failure path), and on a CI runner executing
|
||||
/// many suites in parallel that Task can simply not be scheduled for
|
||||
/// seconds. At five seconds this timed out on CI and reported *two*
|
||||
/// failures — the wait itself, and the `!isPlaying` that the un-run failure
|
||||
/// path had not yet reset — which reads like a playback bug rather than a
|
||||
/// starved scheduler.
|
||||
///
|
||||
/// A generous deadline costs nothing when the condition holds, since this
|
||||
/// returns as soon as it does; it only extends the genuine-failure case.
|
||||
private func waitUntil(
|
||||
_ condition: () -> Bool,
|
||||
sourceLocation: SourceLocation = #_sourceLocation
|
||||
) async {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||
while !condition(), ContinuousClock.now < deadline {
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
|
||||
@@ -11,7 +11,6 @@ import Foundation
|
||||
// Kept local until the test-helper module is split out.
|
||||
struct TestConstants {
|
||||
static let defaultTimeout: TimeInterval = 5.0
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
|
||||
Reference in New Issue
Block a user