mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 14:45:23 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0152196ac2 | ||
|
|
14e7b428d9 | ||
|
|
c671e3df66 | ||
|
|
132120a88e |
@@ -63,7 +63,13 @@ jobs:
|
|||||||
echo "#"
|
echo "#"
|
||||||
echo "# Verify a checkout of this ref with:"
|
echo "# Verify a checkout of this ref with:"
|
||||||
echo "# shasum -a 256 -c files.sha256"
|
echo "# shasum -a 256 -c files.sha256"
|
||||||
echo "# The git tree hash above is the single value that covers all of it:"
|
echo "# Hash checking alone ignores files this manifest does not list, and"
|
||||||
|
echo "# the Xcode project compiles any source file present in the tree. So"
|
||||||
|
echo "# also confirm nothing extra is present:"
|
||||||
|
echo "# git status --porcelain --ignored # git checkout: must print nothing"
|
||||||
|
echo "# or, for a tarball, diff this manifest's path list against find(1)."
|
||||||
|
echo "# Full instructions: docs/VERIFYING-A-BUILD.md"
|
||||||
|
echo "# The git tree hash above is the single value covering all tracked content:"
|
||||||
echo "# git rev-parse HEAD^{tree}"
|
echo "# git rev-parse HEAD^{tree}"
|
||||||
echo "#"
|
echo "#"
|
||||||
} > SOURCE-MANIFEST.txt
|
} > SOURCE-MANIFEST.txt
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
bitchat is a security-focused messenger, and reports about its security are taken seriously. This page says how to report, what counts as a vulnerability here, and what to expect.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Use GitHub's private vulnerability reporting:** [Report a vulnerability](https://github.com/permissionlesstech/bitchat/security/advisories/new) (Security tab → "Report a vulnerability").
|
||||||
|
|
||||||
|
Please do not open a public issue for anything that could put people at risk before a fix ships. bitchat is used by people in hostile network environments; a public proof-of-concept can be acted on faster than a patch can reach them.
|
||||||
|
|
||||||
|
A useful report says what an attacker can do, against which build (App Store version or commit hash), and how to reproduce it. A failing test or a packet capture is worth more than speculation about impact.
|
||||||
|
|
||||||
|
## What to expect
|
||||||
|
|
||||||
|
This is a volunteer-maintained project. The aim is to acknowledge reports within a week and to move on confirmed vulnerabilities immediately — historically, confirmed protocol and key-handling issues have been fixed within days. You'll be kept in the loop in the advisory thread, and credited in the fix unless you'd rather not be. There is no bug bounty.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Fixes ship to the latest App Store release and `main`. Older releases are not patched; the fix is to update.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope — the properties the app promises:
|
||||||
|
|
||||||
|
- Confidentiality and integrity of private messages and media (Noise sessions over BLE; over Nostr, bitchat's own ephemeral private-envelope format — a proprietary scheme, *not* NIP-17/NIP-44/NIP-59, see `WHITEPAPER.md`)
|
||||||
|
- Identity: key handling, verification, impersonation, session binding
|
||||||
|
- The panic wipe actually destroying what it claims to destroy
|
||||||
|
- Metadata exposure beyond what the documentation already discloses (see `PRIVACY_POLICY.md` and `docs/privacy-assessment.md`)
|
||||||
|
- Downgrade paths: anything that silently moves traffic from an encrypted path to a plaintext one
|
||||||
|
- Tor routing: anything that makes traffic bypass Tor while the Tor preference is on
|
||||||
|
- Supply-chain integrity of the source and its vendored binaries (see `docs/VERIFYING-A-BUILD.md`)
|
||||||
|
|
||||||
|
Out of scope — documented design properties, not vulnerabilities:
|
||||||
|
|
||||||
|
- Public visibility of mesh announces and geohash channels: broadcast content, nicknames, and public keys are public by design
|
||||||
|
- Bluetooth proximity being observable: anyone in radio range can tell a BLE device is present
|
||||||
|
- Mesh flooding/relay behavior inherent to a broadcast mesh (rate limits exist; the topology is what it is)
|
||||||
|
- Behavior of third-party Nostr relays
|
||||||
|
- Denial of service requiring physical proximity, and battery-drain attacks in general
|
||||||
|
|
||||||
|
If you're unsure whether something is in scope, report it privately anyway — a false alarm costs a few minutes; a real issue reported publicly can cost much more.
|
||||||
|
|
||||||
|
## Verifying what you're running
|
||||||
|
|
||||||
|
If your concern is that the app or source you have has been tampered with, that has its own document: `docs/VERIFYING-A-BUILD.md`.
|
||||||
@@ -848,6 +848,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
messageQueueLock.unlock()
|
messageQueueLock.unlock()
|
||||||
|
// A relay queued while Tor bootstraps would otherwise reconnect when
|
||||||
|
// the queue drains, overriding the explicit removal.
|
||||||
|
pendingTorConnectionURLs.subtract(urls)
|
||||||
relays.removeAll { urls.contains($0.url) }
|
relays.removeAll { urls.contains($0.url) }
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ extension ChatViewModel {
|
|||||||
@objc func handleTorBootstrapDidStall() {
|
@objc func handleTorBootstrapDidStall() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard TorManager.shared.torEnforced else { return }
|
guard TorManager.shared.torEnforced else { return }
|
||||||
|
// torEnforced is a compile-time constant in release builds; the
|
||||||
|
// runtime preference is what says whether anyone is waiting on
|
||||||
|
// Tor. Turning Tor off mid-bootstrap must not read as blocking.
|
||||||
|
guard NetworkActivationService.persistedTorPreference() else { return }
|
||||||
guard !self.torStallAnnounced else { return }
|
guard !self.torStallAnnounced else { return }
|
||||||
self.torStallAnnounced = true
|
self.torStallAnnounced = true
|
||||||
self.addGeohashOnlySystemMessage(
|
self.addGeohashOnlySystemMessage(
|
||||||
|
|||||||
@@ -73,7 +73,14 @@ struct ContentComposerView: View {
|
|||||||
.textInputAutocapitalization(.sentences)
|
.textInputAutocapitalization(.sentences)
|
||||||
#endif
|
#endif
|
||||||
.submitLabel(.send)
|
.submitLabel(.send)
|
||||||
.onSubmit(onSendMessage)
|
.onSubmit {
|
||||||
|
onSendMessage()
|
||||||
|
// Only the return-key path: it steals focus on iOS, so
|
||||||
|
// every message would cost a tap to reopen the keyboard.
|
||||||
|
// The send button must not reopen a deliberately
|
||||||
|
// dismissed keyboard, so it stays out of this.
|
||||||
|
isTextFieldFocused.wrappedValue = true
|
||||||
|
}
|
||||||
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
|
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.themedInputBackground()
|
.themedInputBackground()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ struct BLEServiceCoreTests {
|
|||||||
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
|
||||||
let receivedDuplicate = await TestHelpers.waitUntil(
|
let receivedDuplicate = await TestHelpers.waitUntil(
|
||||||
{ delegate.publicMessagesSnapshot().count > 1 },
|
{ delegate.publicMessagesSnapshot().count > 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!receivedDuplicate)
|
#expect(!receivedDuplicate)
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ struct BLEServiceCoreTests {
|
|||||||
|
|
||||||
let unsignedRelayed = await TestHelpers.waitUntil(
|
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .leave) > 0 },
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!unsignedRelayed)
|
#expect(!unsignedRelayed)
|
||||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
@@ -133,7 +133,7 @@ struct BLEServiceCoreTests {
|
|||||||
|
|
||||||
let badSignatureRelayed = await TestHelpers.waitUntil(
|
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .leave) > 0 },
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!badSignatureRelayed)
|
#expect(!badSignatureRelayed)
|
||||||
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
@@ -1209,7 +1209,7 @@ struct BLEServiceCoreTests {
|
|||||||
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
gate.release()
|
gate.release()
|
||||||
continuation.resume(returning: didObserveClosure)
|
continuation.resume(returning: didObserveClosure)
|
||||||
@@ -1340,7 +1340,7 @@ struct BLEServiceCoreTests {
|
|||||||
// rotated sender IDs never bought a sixth response.
|
// rotated sender IDs never bought a sixth response.
|
||||||
let exceededBudget = await TestHelpers.waitUntil(
|
let exceededBudget = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .pong) > budget },
|
{ outbound.count(ofType: .pong) > budget },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!exceededBudget)
|
#expect(!exceededBudget)
|
||||||
#expect(outbound.count(ofType: .pong) == budget)
|
#expect(outbound.count(ofType: .pong) == budget)
|
||||||
|
|||||||
@@ -1374,3 +1374,37 @@ private func makeImageData() throws -> Data {
|
|||||||
return data
|
return data
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tor Extension Tests
|
||||||
|
|
||||||
|
struct ChatViewModelTorExtensionTests {
|
||||||
|
|
||||||
|
/// Turning Tor off mid-bootstrap must not read as "the network is
|
||||||
|
/// blocking tor": `torEnforced` is a compile-time constant, so the stall
|
||||||
|
/// handler has to consult the runtime preference before announcing.
|
||||||
|
@Test @MainActor
|
||||||
|
func bootstrapStall_withTorPreferenceOff_announcesNothing() async {
|
||||||
|
let key = NetworkActivationService.torPreferenceKey
|
||||||
|
let previous = UserDefaults.standard.object(forKey: key)
|
||||||
|
defer {
|
||||||
|
if let previous {
|
||||||
|
UserDefaults.standard.set(previous, forKey: key)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|
||||||
|
UserDefaults.standard.set(false, forKey: key)
|
||||||
|
viewModel.handleTorBootstrapDidStall()
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
#expect(viewModel.torStallAnnounced == false)
|
||||||
|
|
||||||
|
// The same stall with the preference on (the persisted default) is
|
||||||
|
// exactly what must still be announced.
|
||||||
|
UserDefaults.standard.set(true, forKey: key)
|
||||||
|
viewModel.handleTorBootstrapDidStall()
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
#expect(viewModel.torStallAnnounced == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ struct ChatViewModelRefactoringTests {
|
|||||||
transport.simulateConnect(peerID, nickname: "alice")
|
transport.simulateConnect(peerID, nickname: "alice")
|
||||||
|
|
||||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
|
||||||
timeout: TestConstants.shortTimeout)
|
timeout: TestConstants.settleTimeout)
|
||||||
#expect(didResolve)
|
#expect(didResolve)
|
||||||
|
|
||||||
// Action: User types /msg command
|
// Action: User types /msg command
|
||||||
viewModel.sendMessage("/msg @alice Hello Private World")
|
viewModel.sendMessage("/msg @alice Hello Private World")
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
|
||||||
timeout: TestConstants.shortTimeout)
|
timeout: TestConstants.settleTimeout)
|
||||||
#expect(didSend)
|
#expect(didSend)
|
||||||
|
|
||||||
// Assert:
|
// Assert:
|
||||||
@@ -74,7 +74,7 @@ struct ChatViewModelRefactoringTests {
|
|||||||
transport.simulateConnect(peerID, nickname: "troll")
|
transport.simulateConnect(peerID, nickname: "troll")
|
||||||
|
|
||||||
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
|
||||||
timeout: TestConstants.shortTimeout)
|
timeout: TestConstants.settleTimeout)
|
||||||
#expect(didResolve)
|
#expect(didResolve)
|
||||||
|
|
||||||
// Action
|
// Action
|
||||||
@@ -83,7 +83,7 @@ struct ChatViewModelRefactoringTests {
|
|||||||
// Assert
|
// Assert
|
||||||
// Verify identity manager was called to block "fingerprint_123"
|
// Verify identity manager was called to block "fingerprint_123"
|
||||||
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
|
||||||
timeout: TestConstants.shortTimeout)
|
timeout: TestConstants.settleTimeout)
|
||||||
#expect(didBlock)
|
#expect(didBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ struct ChatViewModelRefactoringTests {
|
|||||||
// Wait for async processing with proper timeout
|
// Wait for async processing with proper timeout
|
||||||
let found = await TestHelpers.waitUntil(
|
let found = await TestHelpers.waitUntil(
|
||||||
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -140,7 +140,7 @@ struct ChatViewModelRefactoringTests {
|
|||||||
{
|
{
|
||||||
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
viewModel.publicMessages(for: .mesh).contains(where: { $0.content == "Public Hi" })
|
||||||
},
|
},
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ struct ChatViewModelCommandTests {
|
|||||||
transport.simulateConnect(peerID, nickname: "Alice")
|
transport.simulateConnect(peerID, nickname: "Alice")
|
||||||
let resolved = await TestHelpers.waitUntil({
|
let resolved = await TestHelpers.waitUntil({
|
||||||
viewModel.getPeerIDForNickname("Alice") == peerID
|
viewModel.getPeerIDForNickname("Alice") == peerID
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.negativeWaitWindow)
|
||||||
#expect(resolved)
|
#expect(resolved)
|
||||||
|
|
||||||
viewModel.handleCommand("/msg Alice")
|
viewModel.handleCommand("/msg Alice")
|
||||||
@@ -422,7 +422,7 @@ struct ChatViewModelServiceLifecycleTests {
|
|||||||
transport.sentReadReceipts.contains {
|
transport.sentReadReceipts.contains {
|
||||||
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
|
$0.peerID == peerID && $0.receipt.originalMessageID == "read-1"
|
||||||
}
|
}
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.negativeWaitWindow)
|
||||||
|
|
||||||
#expect(sentReadReceipt)
|
#expect(sentReadReceipt)
|
||||||
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
#expect(!viewModel.unreadPrivateMessages.contains(peerID))
|
||||||
@@ -506,7 +506,7 @@ struct ChatViewModelReceivingTests {
|
|||||||
|
|
||||||
let found = await TestHelpers.waitUntil({
|
let found = await TestHelpers.waitUntil({
|
||||||
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
viewModel.publicMessages(for: .mesh).contains { $0.content == "Public hello from Bob" }
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
|
|
||||||
#expect(found)
|
#expect(found)
|
||||||
}
|
}
|
||||||
@@ -535,11 +535,11 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
|
|
||||||
let stored = await TestHelpers.waitUntil({
|
let stored = await TestHelpers.waitUntil({
|
||||||
viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true
|
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({
|
let acked = await TestHelpers.waitUntil({
|
||||||
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID }
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
|
|
||||||
#expect(stored)
|
#expect(stored)
|
||||||
#expect(acked)
|
#expect(acked)
|
||||||
@@ -579,7 +579,7 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
return name == "Bob"
|
return name == "Bob"
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
|
|
||||||
#expect(delivered)
|
#expect(delivered)
|
||||||
}
|
}
|
||||||
@@ -617,7 +617,7 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
|
|
||||||
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
let conversationStoreUpdated = await TestHelpers.waitUntil({
|
||||||
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
let messages = viewModel.conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
@@ -626,7 +626,7 @@ struct ChatViewModelNoisePayloadTests {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
|
|
||||||
#expect(privateChatUpdated)
|
#expect(privateChatUpdated)
|
||||||
#expect(conversationStoreUpdated)
|
#expect(conversationStoreUpdated)
|
||||||
@@ -730,7 +730,7 @@ struct ChatViewModelVerificationTests {
|
|||||||
|
|
||||||
let bound = await TestHelpers.waitUntil({
|
let bound = await TestHelpers.waitUntil({
|
||||||
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID }
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
#expect(bound)
|
#expect(bound)
|
||||||
|
|
||||||
let qr = VerificationService.VerificationQR(
|
let qr = VerificationService.VerificationQR(
|
||||||
@@ -982,7 +982,7 @@ struct ChatViewModelPeerTests {
|
|||||||
|
|
||||||
let cleaned = await TestHelpers.waitUntil({
|
let cleaned = await TestHelpers.waitUntil({
|
||||||
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
!viewModel.unreadPrivateMessages.contains(stalePeer)
|
||||||
}, timeout: TestConstants.defaultTimeout)
|
}, timeout: TestConstants.settleTimeout)
|
||||||
|
|
||||||
#expect(cleaned)
|
#expect(cleaned)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ struct CourierEndToEndTests {
|
|||||||
))
|
))
|
||||||
let deposited = await TestHelpers.waitUntil(
|
let deposited = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
@@ -151,7 +151,7 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(carried)
|
#expect(carried)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ struct CourierEndToEndTests {
|
|||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announced = await TestHelpers.waitUntil(
|
let announced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(announced)
|
#expect(announced)
|
||||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||||
@@ -169,7 +169,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let handedOver = await TestHelpers.waitUntil(
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(handedOver)
|
||||||
// With CoreBluetooth disabled there is no physical link for the send
|
// With CoreBluetooth disabled there is no physical link for the send
|
||||||
@@ -183,7 +183,7 @@ struct CourierEndToEndTests {
|
|||||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||||
let received = await TestHelpers.waitUntil(
|
let received = await TestHelpers.waitUntil(
|
||||||
{ !bobDelegate.snapshot().isEmpty },
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(received)
|
#expect(received)
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ struct CourierEndToEndTests {
|
|||||||
))
|
))
|
||||||
let deposited = await TestHelpers.waitUntil(
|
let deposited = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
@@ -237,7 +237,7 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(carried)
|
#expect(carried)
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ struct CourierEndToEndTests {
|
|||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announced = await TestHelpers.waitUntil(
|
let announced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(announced)
|
#expect(announced)
|
||||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||||
@@ -253,7 +253,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let handedOver = await TestHelpers.waitUntil(
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(handedOver)
|
||||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||||
@@ -265,7 +265,7 @@ struct CourierEndToEndTests {
|
|||||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||||
let delivered = await TestHelpers.waitUntil(
|
let delivered = await TestHelpers.waitUntil(
|
||||||
{ !bobDelegate.snapshot().isEmpty },
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!delivered)
|
#expect(!delivered)
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ struct CourierEndToEndTests {
|
|||||||
))
|
))
|
||||||
let deposited = await TestHelpers.waitUntil(
|
let deposited = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
@@ -301,7 +301,7 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(carried)
|
#expect(carried)
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!leakedOnUnverifiedAnnounce)
|
#expect(!leakedOnUnverifiedAnnounce)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
@@ -318,7 +318,7 @@ struct CourierEndToEndTests {
|
|||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announced = await TestHelpers.waitUntil(
|
let announced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(announced)
|
#expect(announced)
|
||||||
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||||
@@ -326,7 +326,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let handedOver = await TestHelpers.waitUntil(
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(handedOver)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
@@ -355,7 +355,7 @@ struct CourierEndToEndTests {
|
|||||||
))
|
))
|
||||||
let deposited = await TestHelpers.waitUntil(
|
let deposited = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
@@ -363,14 +363,14 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(carried)
|
#expect(carried)
|
||||||
|
|
||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announced = await TestHelpers.waitUntil(
|
let announced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(announced)
|
#expect(announced)
|
||||||
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||||
@@ -385,7 +385,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let remoteHandover = await TestHelpers.waitUntil(
|
let remoteHandover = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(remoteHandover)
|
#expect(remoteHandover)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
@@ -398,7 +398,7 @@ struct CourierEndToEndTests {
|
|||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let reannounced = await TestHelpers.waitUntil(
|
let reannounced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(reannounced)
|
#expect(reannounced)
|
||||||
let freshAnnounce = try #require(
|
let freshAnnounce = try #require(
|
||||||
@@ -410,7 +410,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let refloodedInCooldown = await TestHelpers.waitUntil(
|
let refloodedInCooldown = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!refloodedInCooldown)
|
#expect(!refloodedInCooldown)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
@@ -424,7 +424,7 @@ struct CourierEndToEndTests {
|
|||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let announcedAgain = await TestHelpers.waitUntil(
|
let announcedAgain = await TestHelpers.waitUntil(
|
||||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
|
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(announcedAgain)
|
#expect(announcedAgain)
|
||||||
let directAgain = try #require(
|
let directAgain = try #require(
|
||||||
@@ -434,7 +434,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
|
let handedOverWithoutLinkProof = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!handedOverWithoutLinkProof)
|
#expect(!handedOverWithoutLinkProof)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
@@ -457,7 +457,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let queuedPacket = await TestHelpers.waitUntil(
|
let queuedPacket = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!queuedPacket)
|
#expect(!queuedPacket)
|
||||||
}
|
}
|
||||||
@@ -494,7 +494,7 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||||
let stored = await TestHelpers.waitUntil(
|
let stored = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!stored)
|
#expect(!stored)
|
||||||
}
|
}
|
||||||
@@ -532,7 +532,7 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||||
let stored = await TestHelpers.waitUntil(
|
let stored = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!stored)
|
#expect(!stored)
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ struct CourierEndToEndTests {
|
|||||||
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
||||||
let stored = await TestHelpers.waitUntil(
|
let stored = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!stored)
|
#expect(!stored)
|
||||||
}
|
}
|
||||||
@@ -602,14 +602,14 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let delivered = await TestHelpers.waitUntil(
|
let delivered = await TestHelpers.waitUntil(
|
||||||
{ !bobDelegate.snapshot().isEmpty },
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(delivered)
|
#expect(delivered)
|
||||||
// Give a duplicate delivery a chance to surface, then confirm the
|
// Give a duplicate delivery a chance to surface, then confirm the
|
||||||
// second copy never reached the delegate.
|
// second copy never reached the delegate.
|
||||||
let duplicated = await TestHelpers.waitUntil(
|
let duplicated = await TestHelpers.waitUntil(
|
||||||
{ bobDelegate.snapshot().count > 1 },
|
{ bobDelegate.snapshot().count > 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!duplicated)
|
#expect(!duplicated)
|
||||||
#expect(bobDelegate.snapshot().count == 1)
|
#expect(bobDelegate.snapshot().count == 1)
|
||||||
@@ -629,7 +629,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
let initiated = await TestHelpers.waitUntil(
|
let initiated = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!initiated)
|
#expect(!initiated)
|
||||||
|
|
||||||
@@ -639,7 +639,7 @@ struct CourierEndToEndTests {
|
|||||||
ble.sendDeliveryAck(for: "msg-2", to: present)
|
ble.sendDeliveryAck(for: "msg-2", to: present)
|
||||||
let initiatedForPresent = await TestHelpers.waitUntil(
|
let initiatedForPresent = await TestHelpers.waitUntil(
|
||||||
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
{ outbound.count(ofType: .noiseHandshake) > 0 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(initiatedForPresent)
|
#expect(initiatedForPresent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ struct PrekeyEndToEndTests {
|
|||||||
peer.sendBroadcastAnnounce()
|
peer.sendBroadcastAnnounce()
|
||||||
let published = await TestHelpers.waitUntil(
|
let published = await TestHelpers.waitUntil(
|
||||||
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
|
{ tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(published)
|
#expect(published)
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +124,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let cached = await TestHelpers.waitUntil(
|
let cached = await TestHelpers.waitUntil(
|
||||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(cached)
|
#expect(cached)
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ struct PrekeyEndToEndTests {
|
|||||||
))
|
))
|
||||||
let deposited = await TestHelpers.waitUntil(
|
let deposited = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
@@ -149,7 +149,7 @@ struct PrekeyEndToEndTests {
|
|||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(carried)
|
#expect(carried)
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ struct PrekeyEndToEndTests {
|
|||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let reannounced = await TestHelpers.waitUntil(
|
let reannounced = await TestHelpers.waitUntil(
|
||||||
{ bobOut.first(ofType: .announce) != nil },
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(reannounced)
|
#expect(reannounced)
|
||||||
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
|
let handoverTrigger = try #require(bobOut.first(ofType: .announce))
|
||||||
@@ -166,7 +166,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let handedOver = await TestHelpers.waitUntil(
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(handedOver)
|
||||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||||
@@ -178,7 +178,7 @@ struct PrekeyEndToEndTests {
|
|||||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||||
let received = await TestHelpers.waitUntil(
|
let received = await TestHelpers.waitUntil(
|
||||||
{ !bobDelegate.snapshot().isEmpty },
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(received)
|
#expect(received)
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ struct PrekeyEndToEndTests {
|
|||||||
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
|
bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
|
||||||
let redelivered = await TestHelpers.waitUntil(
|
let redelivered = await TestHelpers.waitUntil(
|
||||||
{ bobDelegate.snapshot().count == 2 },
|
{ bobDelegate.snapshot().count == 2 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!redelivered)
|
#expect(!redelivered)
|
||||||
#expect(bobDelegate.snapshot().count == 1)
|
#expect(bobDelegate.snapshot().count == 1)
|
||||||
@@ -235,7 +235,7 @@ struct PrekeyEndToEndTests {
|
|||||||
))
|
))
|
||||||
let deposited = await TestHelpers.waitUntil(
|
let deposited = await TestHelpers.waitUntil(
|
||||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
@@ -248,7 +248,7 @@ struct PrekeyEndToEndTests {
|
|||||||
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
|
bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
|
||||||
let received = await TestHelpers.waitUntil(
|
let received = await TestHelpers.waitUntil(
|
||||||
{ !bobDelegate.snapshot().isEmpty },
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(received)
|
#expect(received)
|
||||||
let delivered = try #require(bobDelegate.snapshot().first)
|
let delivered = try #require(bobDelegate.snapshot().first)
|
||||||
@@ -272,7 +272,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let cached = await TestHelpers.waitUntil(
|
let cached = await TestHelpers.waitUntil(
|
||||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!cached)
|
#expect(!cached)
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let cached = await TestHelpers.waitUntil(
|
let cached = await TestHelpers.waitUntil(
|
||||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!cached)
|
#expect(!cached)
|
||||||
}
|
}
|
||||||
@@ -328,7 +328,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let cached = await TestHelpers.waitUntil(
|
let cached = await TestHelpers.waitUntil(
|
||||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(cached)
|
#expect(cached)
|
||||||
// The verified bundle now participates in Alice's sync rounds.
|
// The verified bundle now participates in Alice's sync rounds.
|
||||||
@@ -364,7 +364,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let cached = await TestHelpers.waitUntil(
|
let cached = await TestHelpers.waitUntil(
|
||||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!cached)
|
#expect(!cached)
|
||||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||||
@@ -396,7 +396,7 @@ struct PrekeyEndToEndTests {
|
|||||||
|
|
||||||
let cached = await TestHelpers.waitUntil(
|
let cached = await TestHelpers.waitUntil(
|
||||||
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
{ alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!cached)
|
#expect(!cached)
|
||||||
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
#expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct GossipSyncManagerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
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")
|
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
|
||||||
@@ -394,7 +394,7 @@ struct GossipSyncManagerTests {
|
|||||||
)
|
)
|
||||||
manager.handleRequestSync(from: peer, request: request)
|
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.
|
// Barrier: flush the sync queue so a late third packet would be visible.
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
let sentPackets = delegate.packets
|
let sentPackets = delegate.packets
|
||||||
@@ -477,7 +477,7 @@ struct GossipSyncManagerTests {
|
|||||||
manager.handleRequestSync(from: peer, request: request)
|
manager.handleRequestSync(from: peer, request: request)
|
||||||
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.
|
// Barrier: both requests have been processed once this returns.
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
#expect(delegate.packets.count == 1)
|
#expect(delegate.packets.count == 1)
|
||||||
@@ -498,7 +498,7 @@ struct GossipSyncManagerTests {
|
|||||||
|
|
||||||
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
|
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 packet = try #require(delegate.packets.first)
|
||||||
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
|
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
|
||||||
let types = try #require(request.types)
|
let types = try #require(request.types)
|
||||||
@@ -553,7 +553,7 @@ struct GossipSyncManagerTests {
|
|||||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
|
||||||
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)
|
||||||
let sentPackets = delegate.packets
|
let sentPackets = delegate.packets
|
||||||
#expect(sentPackets.count == 1)
|
#expect(sentPackets.count == 1)
|
||||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||||
@@ -615,7 +615,7 @@ struct GossipSyncManagerTests {
|
|||||||
)
|
)
|
||||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
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.
|
// Barrier: flush the sync queue so a late second packet would be visible.
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
let sentPackets = delegate.packets
|
let sentPackets = delegate.packets
|
||||||
@@ -641,7 +641,7 @@ struct GossipSyncManagerTests {
|
|||||||
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
let stalledID = try #require(Data(hexString: "0102030405060708"))
|
||||||
manager.requestMissingFragments(fragmentIDs: [stalledID])
|
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)
|
let sent = try #require(delegate.packets.first)
|
||||||
#expect(sent.type == MessageType.requestSync.rawValue)
|
#expect(sent.type == MessageType.requestSync.rawValue)
|
||||||
#expect(sent.ttl == 0)
|
#expect(sent.ttl == 0)
|
||||||
@@ -697,7 +697,7 @@ struct GossipSyncManagerTests {
|
|||||||
// And a .prekeyBundle sync request is answered with the stored packet.
|
// And a .prekeyBundle sync request is answered with the stored packet.
|
||||||
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
|
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
|
||||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
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)
|
let served = try #require(delegate.packets.first)
|
||||||
#expect(served.type == MessageType.prekeyBundle.rawValue)
|
#expect(served.type == MessageType.prekeyBundle.rawValue)
|
||||||
#expect(served.isRSR)
|
#expect(served.isRSR)
|
||||||
@@ -774,7 +774,7 @@ struct GossipSyncManagerTests {
|
|||||||
)
|
)
|
||||||
let restored = await TestHelpers.waitUntil(
|
let restored = await TestHelpers.waitUntil(
|
||||||
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
|
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(restored)
|
#expect(restored)
|
||||||
}
|
}
|
||||||
@@ -844,7 +844,7 @@ struct GossipSyncManagerTests {
|
|||||||
!FileManager.default.fileExists(atPath: fileURL.path)
|
!FileManager.default.fileExists(atPath: fileURL.path)
|
||||||
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
|
&& manager._messageCount(for: PeerID(hexData: senderID)) == 0
|
||||||
},
|
},
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(erased)
|
#expect(erased)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ final class NearbyNotesCounterTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () -> Bool
|
condition: @escaping @MainActor () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -723,9 +723,9 @@ struct NoiseCoverageTests {
|
|||||||
// A failed startup requirement must not strand a late thread in
|
// A failed startup requirement must not strand a late thread in
|
||||||
// the blocking test double after the test has returned.
|
// the blocking test double after the test has returned.
|
||||||
oldSession.resumeDecrypt()
|
oldSession.resumeDecrypt()
|
||||||
_ = decryptResult.wait(timeout: 5)
|
_ = decryptResult.wait(timeout: TestConstants.settleTimeout)
|
||||||
if let promotionResultForCleanup {
|
if let promotionResultForCleanup {
|
||||||
_ = promotionResultForCleanup.wait(timeout: 5)
|
_ = promotionResultForCleanup.wait(timeout: TestConstants.settleTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,15 +751,19 @@ struct NoiseCoverageTests {
|
|||||||
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote"
|
||||||
promotionThread.qualityOfService = .userInitiated
|
promotionThread.qualityOfService = .userInitiated
|
||||||
promotionThread.start()
|
promotionThread.start()
|
||||||
try #require(promotionStarted.wait(timeout: .now() + 5) == .success)
|
try #require(promotionStarted.wait(timeout: .now() + TestConstants.settleTimeout) == .success)
|
||||||
#expect(
|
#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,
|
promotionResult.wait(timeout: 0.05) == nil,
|
||||||
"Promotion must wait for the exact decrypting-session lease"
|
"Promotion must wait for the exact decrypting-session lease"
|
||||||
)
|
)
|
||||||
|
|
||||||
oldSession.resumeDecrypt()
|
oldSession.resumeDecrypt()
|
||||||
let decrypted = try #require(decryptResult.wait(timeout: 5)).get()
|
let decrypted = try #require(decryptResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||||
_ = try #require(promotionResult.wait(timeout: 5)).get()
|
_ = try #require(promotionResult.wait(timeout: TestConstants.settleTimeout)).get()
|
||||||
|
|
||||||
#expect(decrypted.plaintext == Data("old session".utf8))
|
#expect(decrypted.plaintext == Data("old session".utf8))
|
||||||
#expect(decrypted.sessionGeneration == oldGeneration)
|
#expect(decrypted.sessionGeneration == oldGeneration)
|
||||||
|
|||||||
@@ -580,8 +580,16 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
/// constrained CI runners (2-core, serialized testing) can starve the
|
/// constrained CI runners (2-core, serialized testing) can starve the
|
||||||
/// detached utility-priority fetch task for seconds before it runs, and
|
/// detached utility-priority fetch task for seconds before it runs, and
|
||||||
/// a successful wait returns as soon as the condition becomes true.
|
/// 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(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 10.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () async -> Bool
|
condition: @escaping @MainActor () async -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ struct PTTBurstPlayerTests {
|
|||||||
_ condition: () -> Bool,
|
_ condition: () -> Bool,
|
||||||
sourceLocation: SourceLocation = #_sourceLocation
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
) async {
|
) async {
|
||||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||||
while !condition(), ContinuousClock.now < deadline {
|
while !condition(), ContinuousClock.now < deadline {
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
|||||||
|
|
||||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
service.addFavorite(peerNoisePublicKey: peerKey, peerNostrPublicKey: "npub1alice", peerNickname: "Alice")
|
||||||
|
|
||||||
wait(for: [expectation], timeout: 1.0)
|
wait(for: [expectation], timeout: TestConstants.settleTimeout)
|
||||||
XCTAssertTrue(service.isFavorite(peerKey))
|
XCTAssertTrue(service.isFavorite(peerKey))
|
||||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Alice")
|
||||||
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
XCTAssertNotNil(keychain.load(key: storageKey, service: serviceKey))
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () -> Bool
|
condition: @escaping @MainActor () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ final class LocationStateManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () -> Bool
|
condition: @escaping @MainActor () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
context.service.start()
|
context.service.start()
|
||||||
context.service.setUserTorEnabled(false)
|
context.service.setUserTorEnabled(false)
|
||||||
|
|
||||||
wait(for: [notified], timeout: 1.0)
|
wait(for: [notified], timeout: TestConstants.negativeWaitWindow)
|
||||||
context.notificationCenter.removeObserver(token)
|
context.notificationCenter.removeObserver(token)
|
||||||
|
|
||||||
XCTAssertFalse(context.service.userTorEnabled)
|
XCTAssertFalse(context.service.userTorEnabled)
|
||||||
@@ -243,7 +243,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () -> Bool
|
condition: @escaping @MainActor () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -69,25 +69,45 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
|||||||
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
XCTAssertNil(d.pendingRemaining(at: t0.addingTimeInterval(2.5)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_monitor_duplicateUpdatesDoNotPostponeOfflineCommit() async {
|
/// Wiring only: a duplicate mid-window still yields exactly one committed
|
||||||
let monitor = NWPathReachabilityMonitor(debounceInterval: 1.0)
|
/// `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] = []
|
var received: [Bool] = []
|
||||||
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
let cancellable = monitor.reachabilityPublisher.sink { received.append($0) }
|
||||||
defer { cancellable.cancel() }
|
defer { cancellable.cancel() }
|
||||||
|
|
||||||
let start = Date()
|
|
||||||
monitor.ingest(reachable: false)
|
monitor.ingest(reachable: false)
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
// Duplicate unsatisfied update mid-window (e.g. an interface detail
|
||||||
// Duplicate unsatisfied update mid-window (e.g. interface detail change
|
// change while still offline).
|
||||||
// while still offline) must not restart the debounce window.
|
clock.now = clock.now.addingTimeInterval(0.1)
|
||||||
monitor.ingest(reachable: false)
|
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)
|
XCTAssertTrue(committed)
|
||||||
XCTAssertEqual(received, [false])
|
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
|
// MARK: - Service gating
|
||||||
@@ -179,7 +199,7 @@ final class NetworkReachabilityGateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () -> Bool
|
condition: @escaping @MainActor () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
@@ -239,3 +259,13 @@ private final class GateMockProxyController: NetworkActivationProxyControlling {
|
|||||||
private(set) var proxyModes: [Bool] = []
|
private(set) var proxyModes: [Bool] = []
|
||||||
func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
|
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)
|
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)
|
#expect(authenticated)
|
||||||
let generationAuthenticated = await TestHelpers.waitUntil(
|
let generationAuthenticated = await TestHelpers.waitUntil(
|
||||||
{ recorder.generationCount >= 1 },
|
{ recorder.generationCount >= 1 },
|
||||||
timeout: 5.0
|
timeout: TestConstants.settleTimeout
|
||||||
)
|
)
|
||||||
#expect(generationAuthenticated)
|
#expect(generationAuthenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
@@ -166,7 +166,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||||
let emittedAuthentication = await TestHelpers.waitUntil(
|
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||||
{ recorder.count > 0 },
|
{ recorder.count > 0 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!emittedAuthentication)
|
#expect(!emittedAuthentication)
|
||||||
}
|
}
|
||||||
@@ -216,7 +216,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||||
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||||
{ recorder.count > 1 },
|
{ recorder.count > 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.negativeWaitWindow
|
||||||
)
|
)
|
||||||
#expect(!emittedReplacementAuthentication)
|
#expect(!emittedReplacementAuthentication)
|
||||||
}
|
}
|
||||||
@@ -652,12 +652,12 @@ struct NoiseEncryptionServiceTests {
|
|||||||
)
|
)
|
||||||
let retried = await TestHelpers.waitUntil(
|
let retried = await TestHelpers.waitUntil(
|
||||||
{ recorder.messages.count == 1 },
|
{ recorder.messages.count == 1 },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(retried)
|
#expect(retried)
|
||||||
let retryExpired = await TestHelpers.waitUntil(
|
let retryExpired = await TestHelpers.waitUntil(
|
||||||
{ !service.hasSession(with: peerID) },
|
{ !service.hasSession(with: peerID) },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(retryExpired)
|
#expect(retryExpired)
|
||||||
#expect(recorder.timeoutCount == 1)
|
#expect(recorder.timeoutCount == 1)
|
||||||
@@ -710,7 +710,12 @@ struct NoiseEncryptionServiceTests {
|
|||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bob = NoiseEncryptionService(
|
let bob = NoiseEncryptionService(
|
||||||
keychain: MockKeychain(),
|
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
|
ordinaryReconnectRollbackCooldown: 0.3
|
||||||
)
|
)
|
||||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
@@ -741,12 +746,12 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
let restored = await TestHelpers.waitUntil(
|
let restored = await TestHelpers.waitUntil(
|
||||||
{ bob.hasEstablishedSession(with: alicePeerID) },
|
{ bob.hasEstablishedSession(with: alicePeerID) },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(restored)
|
#expect(restored)
|
||||||
let callbackArrived = await TestHelpers.waitUntil(
|
let callbackArrived = await TestHelpers.waitUntil(
|
||||||
{ recovery.timeoutCount == 1 },
|
{ recovery.timeoutCount == 1 },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(callbackArrived)
|
#expect(callbackArrived)
|
||||||
|
|
||||||
@@ -780,7 +785,13 @@ struct NoiseEncryptionServiceTests {
|
|||||||
let bob = NoiseEncryptionService(
|
let bob = NoiseEncryptionService(
|
||||||
keychain: MockKeychain(),
|
keychain: MockKeychain(),
|
||||||
ordinaryHandshakeTimeout: 0.04,
|
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 alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
@@ -814,12 +825,12 @@ struct NoiseEncryptionServiceTests {
|
|||||||
// initiates one bounded convergence retry; drop that message 1 too.
|
// initiates one bounded convergence retry; drop that message 1 too.
|
||||||
let retryPrepared = await TestHelpers.waitUntil(
|
let retryPrepared = await TestHelpers.waitUntil(
|
||||||
{ recovery.messages.count == 1 },
|
{ recovery.messages.count == 1 },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(retryPrepared)
|
#expect(retryPrepared)
|
||||||
let retryExpired = await TestHelpers.waitUntil(
|
let retryExpired = await TestHelpers.waitUntil(
|
||||||
{ !bob.hasSession(with: alicePeerID) },
|
{ !bob.hasSession(with: alicePeerID) },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(retryExpired)
|
#expect(retryExpired)
|
||||||
#expect(recovery.timeoutCount == 1)
|
#expect(recovery.timeoutCount == 1)
|
||||||
@@ -1026,7 +1037,7 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
let requested = await TestHelpers.waitUntil(
|
let requested = await TestHelpers.waitUntil(
|
||||||
{ recovery.messages.count == 1 },
|
{ recovery.messages.count == 1 },
|
||||||
timeout: 1
|
timeout: TestConstants.longTimeout
|
||||||
)
|
)
|
||||||
#expect(requested)
|
#expect(requested)
|
||||||
let retryMessage1 = try #require(recovery.messages.first)
|
let retryMessage1 = try #require(recovery.messages.first)
|
||||||
|
|||||||
@@ -44,6 +44,46 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A relay removed while its connection is queued behind Tor bootstrap
|
||||||
|
/// must stay removed: draining the pending set used to resurrect it,
|
||||||
|
/// because `dropRelays` never touched `pendingTorConnectionURLs` and a
|
||||||
|
/// custom relay is in neither the default set nor the allow-list filter.
|
||||||
|
func test_relayRemovedWhileWaitingForTor_staysRemovedWhenTorBecomesReady() async {
|
||||||
|
let customURL = "wss://custom-removed.example"
|
||||||
|
let center = NotificationCenter()
|
||||||
|
let customRelays = MutableRelayList(urls: [customURL])
|
||||||
|
let context = makeContext(
|
||||||
|
permission: .authorized,
|
||||||
|
userTorEnabled: true,
|
||||||
|
torEnforced: true,
|
||||||
|
torIsReady: false,
|
||||||
|
notificationCenter: center,
|
||||||
|
customRelays: customRelays
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults plus the custom relay all queue while Tor bootstraps.
|
||||||
|
context.manager.connect()
|
||||||
|
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||||
|
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||||
|
|
||||||
|
// The relay is removed by hand before Tor is ready.
|
||||||
|
customRelays.urls = []
|
||||||
|
center.post(name: NostrRelaySettings.didChangeNotification, object: nil)
|
||||||
|
// The settings sink hops through the main queue; let it land.
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
context.torWaiter.resolve(true)
|
||||||
|
|
||||||
|
let defaultsConnected = await waitUntil {
|
||||||
|
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount
|
||||||
|
}
|
||||||
|
XCTAssertTrue(defaultsConnected)
|
||||||
|
XCTAssertFalse(
|
||||||
|
context.sessionFactory.requestedURLs.contains(customURL),
|
||||||
|
"a relay removed while Tor was bootstrapping must not reconnect when the pending queue drains"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {
|
func test_connect_waitsForTorReadinessBeforeCreatingSessions() async {
|
||||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
|
|
||||||
@@ -920,7 +960,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
|
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
|
receivedIDs.count == events.count
|
||||||
}
|
}
|
||||||
XCTAssertTrue(allDelivered)
|
XCTAssertTrue(allDelivered)
|
||||||
@@ -966,7 +1006,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
|
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")
|
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
|
// The signal: B did not have to wait for A's entire backlog. If the two
|
||||||
@@ -979,7 +1019,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Both relays still drain fully and in order.
|
// 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
|
busyDeliveredCount == busyEvents.count
|
||||||
}
|
}
|
||||||
XCTAssertTrue(allDelivered)
|
XCTAssertTrue(allDelivered)
|
||||||
@@ -1867,7 +1907,7 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping @MainActor () -> Bool
|
condition: @escaping @MainActor () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
|
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)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
@@ -209,7 +209,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
|
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)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
let privateMessage = try decodePrivateMessage(from: result.payload)
|
let privateMessage = try decodePrivateMessage(from: result.payload)
|
||||||
@@ -250,7 +250,7 @@ struct NostrTransportTests {
|
|||||||
|
|
||||||
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
|
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)
|
#expect(didSend)
|
||||||
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ struct NostrTransportTests {
|
|||||||
messageID: "geo-1"
|
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)
|
#expect(didSend)
|
||||||
let event = probe.sentEvents[0]
|
let event = probe.sentEvents[0]
|
||||||
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
|
||||||
|
|||||||
@@ -562,7 +562,7 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping () -> Bool
|
condition: @escaping () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ struct SecureIdentityStateManagerVouchTests {
|
|||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping () -> Bool
|
condition: @escaping () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct GossipSyncBoardTests {
|
|||||||
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
|
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)
|
let sent = try #require(delegate.packets.first)
|
||||||
#expect(sent.type == MessageType.boardPost.rawValue)
|
#expect(sent.type == MessageType.boardPost.rawValue)
|
||||||
#expect(sent.isRSR)
|
#expect(sent.isRSR)
|
||||||
@@ -69,7 +69,7 @@ struct GossipSyncBoardTests {
|
|||||||
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
|
||||||
manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
|
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.count == 1)
|
||||||
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
#expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final class RequestSyncManagerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func waitUntil(
|
private func waitUntil(
|
||||||
timeout: TimeInterval = 1.0,
|
timeout: TimeInterval = TestConstants.settleTimeout,
|
||||||
condition: @escaping () -> Bool
|
condition: @escaping () -> Bool
|
||||||
) async -> Bool {
|
) async -> Bool {
|
||||||
let deadline = Date().addingTimeInterval(timeout)
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|||||||
@@ -11,14 +11,54 @@ import Foundation
|
|||||||
|
|
||||||
struct TestConstants {
|
struct TestConstants {
|
||||||
static let defaultTimeout: TimeInterval = 5.0
|
static let defaultTimeout: TimeInterval = 5.0
|
||||||
static let shortTimeout: TimeInterval = 1.0
|
|
||||||
/// For positive waits on work that hops through `Task.detached` or
|
/// For positive waits on work that hops through `Task.detached` or
|
||||||
/// background queues: those contend with every parallel test worker for
|
/// background queues: those contend with every parallel test worker for
|
||||||
/// the global executor, so a loaded CI runner can exceed
|
/// the global executor, so a loaded CI runner can exceed
|
||||||
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
|
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
|
||||||
/// so passing runs never pay the longer timeout.
|
/// so passing runs never pay the longer timeout.
|
||||||
static let longTimeout: TimeInterval = 10.0
|
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 testNickname1 = "Alice"
|
||||||
static let testNickname2 = "Bob"
|
static let testNickname2 = "Bob"
|
||||||
static let testNickname3 = "Charlie"
|
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,
|
_ condition: () -> Bool,
|
||||||
sourceLocation: SourceLocation = #_sourceLocation
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
) async {
|
) async {
|
||||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||||
while !condition(), ContinuousClock.now < deadline {
|
while !condition(), ContinuousClock.now < deadline {
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
|||||||
@@ -59,11 +59,24 @@ struct VoiceNotePlaybackControllerTests {
|
|||||||
return url
|
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(
|
private func waitUntil(
|
||||||
_ condition: () -> Bool,
|
_ condition: () -> Bool,
|
||||||
sourceLocation: SourceLocation = #_sourceLocation
|
sourceLocation: SourceLocation = #_sourceLocation
|
||||||
) async {
|
) async {
|
||||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
let deadline = ContinuousClock.now.advanced(by: .seconds(TestConstants.settleTimeout))
|
||||||
while !condition(), ContinuousClock.now < deadline {
|
while !condition(), ContinuousClock.now < deadline {
|
||||||
await Task.yield()
|
await Task.yield()
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||||
|
|||||||
@@ -18,26 +18,43 @@ In order of how much verification is possible:
|
|||||||
|
|
||||||
Every tagged release has a `SOURCE-MANIFEST.txt` produced by `.github/workflows/source-manifest.yml`. It records the tag, the commit, the git tree hash, and a SHA-256 for every tracked file.
|
Every tagged release has a `SOURCE-MANIFEST.txt` produced by `.github/workflows/source-manifest.yml`. It records the tag, the commit, the git tree hash, and a SHA-256 for every tracked file.
|
||||||
|
|
||||||
Check a copy of the source against it:
|
Keep the downloaded manifest *outside* the source tree (say, `/tmp`) — a stray copy inside the checkout would itself trip the completeness checks below. Then check a copy of the source against it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# From the root of the source you obtained
|
# From the root of the source you obtained, with the manifest at /tmp
|
||||||
grep -v '^#' SOURCE-MANIFEST.txt > /tmp/files.sha256
|
grep -v '^#' /tmp/SOURCE-MANIFEST.txt > /tmp/files.sha256
|
||||||
shasum -a 256 -c /tmp/files.sha256
|
shasum -a 256 -c /tmp/files.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
Any `FAILED` line means that file differs from the released source. Investigate before building.
|
Any `FAILED` line means that file differs from the released source. Investigate before building.
|
||||||
|
|
||||||
|
That check alone is not enough. `shasum -c` verifies the files the manifest lists and says nothing about files it does not list — and the Xcode project compiles every source file present in the tree automatically, so a hostile mirror can pass the hash check by leaving every listed file intact and *adding* one. Confirm nothing extra is present:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The manifest's path list must match the tree exactly — no missing files, no extras
|
||||||
|
grep -v '^#' /tmp/SOURCE-MANIFEST.txt | sed 's/^[0-9a-f]* //' | LC_ALL=C sort > /tmp/manifest-paths
|
||||||
|
find . -type f ! -path './.git/*' | sed 's|^\./||' | LC_ALL=C sort > /tmp/actual-paths
|
||||||
|
diff /tmp/manifest-paths /tmp/actual-paths # must print nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
In a git checkout the same assurance is one command — it also catches extra files, because they show as untracked. `--ignored` matters: `.gitignore` covers paths like `build/`, plain `git status` would not report a planted file there, and Xcode compiles it all the same:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --porcelain --ignored # must print nothing before you build
|
||||||
|
```
|
||||||
|
|
||||||
The single value that covers the whole tree is the git tree hash in the manifest header:
|
The single value that covers the whole tree is the git tree hash in the manifest header:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git rev-parse HEAD^{tree} # must equal the "tree:" line in the manifest
|
git rev-parse HEAD^{tree} # must equal the "tree:" line in the manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note the tree hash covers tracked content only; it does not see untracked files sitting in the working directory, which is why the emptiness checks above come first.
|
||||||
|
|
||||||
The manifest itself carries a provenance attestation tying it to the workflow run that produced it, so a manifest handed to you along with a mirror is checkable too:
|
The manifest itself carries a provenance attestation tying it to the workflow run that produced it, so a manifest handed to you along with a mirror is checkable too:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh attestation verify SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat
|
gh attestation verify /tmp/SOURCE-MANIFEST.txt --repo permissionlesstech/bitchat
|
||||||
```
|
```
|
||||||
|
|
||||||
That last step is what makes this resistant to a hostile mirror. Without it, whoever gives you the source can give you a matching manifest.
|
That last step is what makes this resistant to a hostile mirror. Without it, whoever gives you the source can give you a matching manifest.
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ public final class TorManager: ObservableObject {
|
|||||||
private var shutdownsInFlight = 0
|
private var shutdownsInFlight = 0
|
||||||
private var startPendingAfterShutdown = false
|
private var startPendingAfterShutdown = false
|
||||||
private var bootstrapMonitorStarted = false
|
private var bootstrapMonitorStarted = false
|
||||||
|
// Fences the detached poll loop: shutdown, dormancy, and restart each bump
|
||||||
|
// this, so a loop from a previous attempt cannot run out its deadline and
|
||||||
|
// report a stall over state that a newer lifecycle event already owns.
|
||||||
|
private var bootstrapGeneration = 0
|
||||||
private var pathMonitor: NWPathMonitor?
|
private var pathMonitor: NWPathMonitor?
|
||||||
private var isAppForeground: Bool = true
|
private var isAppForeground: Bool = true
|
||||||
private var lastRestartAt: Date? = nil
|
private var lastRestartAt: Date? = nil
|
||||||
@@ -268,24 +272,25 @@ public final class TorManager: ObservableObject {
|
|||||||
private func startBootstrapMonitor() {
|
private func startBootstrapMonitor() {
|
||||||
guard !bootstrapMonitorStarted else { return }
|
guard !bootstrapMonitorStarted else { return }
|
||||||
bootstrapMonitorStarted = true
|
bootstrapMonitorStarted = true
|
||||||
|
bootstrapGeneration += 1
|
||||||
|
let generation = bootstrapGeneration
|
||||||
Task.detached(priority: .utility) { [weak self] in
|
Task.detached(priority: .utility) { [weak self] in
|
||||||
await self?.bootstrapPollLoop()
|
await self?.bootstrapPollLoop(generation: generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bootstrapPollLoop() async {
|
private func bootstrapPollLoop(generation: Int) async {
|
||||||
let deadline = Date().addingTimeInterval(75)
|
let deadline = Date().addingTimeInterval(75)
|
||||||
var didComplete = false
|
var didComplete = false
|
||||||
while Date() < deadline {
|
while Date() < deadline {
|
||||||
|
guard generation == bootstrapGeneration else { return }
|
||||||
let progress = Int(arti_bootstrap_progress())
|
let progress = Int(arti_bootstrap_progress())
|
||||||
let summary = getBootstrapSummary()
|
let summary = getBootstrapSummary()
|
||||||
|
|
||||||
await MainActor.run {
|
self.bootstrapProgress = progress
|
||||||
self.bootstrapProgress = progress
|
self.bootstrapSummary = summary
|
||||||
self.bootstrapSummary = summary
|
if progress >= 100 { self.isStarting = false }
|
||||||
if progress >= 100 { self.isStarting = false }
|
self.recomputeReady()
|
||||||
self.recomputeReady()
|
|
||||||
}
|
|
||||||
|
|
||||||
if progress >= 100 {
|
if progress >= 100 {
|
||||||
didComplete = true
|
didComplete = true
|
||||||
@@ -296,17 +301,17 @@ public final class TorManager: ObservableObject {
|
|||||||
|
|
||||||
// Running out the deadline is a reportable outcome, not silence. The
|
// Running out the deadline is a reportable outcome, not silence. The
|
||||||
// loop previously just ended, leaving `isStarting` true forever, so a
|
// loop previously just ended, leaving `isStarting` true forever, so a
|
||||||
// blocked network was indistinguishable from a slow one.
|
// blocked network was indistinguishable from a slow one. A deliberate
|
||||||
|
// shutdown mid-bootstrap is not a stall, hence the generation check.
|
||||||
if !didComplete {
|
if !didComplete {
|
||||||
await MainActor.run {
|
guard generation == bootstrapGeneration else { return }
|
||||||
self.isStarting = false
|
self.isStarting = false
|
||||||
self.bootstrapDidStall = true
|
self.bootstrapDidStall = true
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
"TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor",
|
"TorManager: bootstrap did not complete within its deadline (progress=\(self.bootstrapProgress)); network may be blocking Tor",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil)
|
NotificationCenter.default.post(name: .TorBootstrapDidStall, object: nil)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +357,7 @@ public final class TorManager: ObservableObject {
|
|||||||
// Clear isStarting so foreground recovery can proceed if bootstrap was interrupted.
|
// Clear isStarting so foreground recovery can proceed if bootstrap was interrupted.
|
||||||
SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session)
|
SecureLogger.debug("TorManager: goDormantOnBackground() called", category: .session)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
|
self.bootstrapGeneration += 1
|
||||||
self.isReady = false
|
self.isReady = false
|
||||||
self.socksReady = false
|
self.socksReady = false
|
||||||
self.isStarting = false
|
self.isStarting = false
|
||||||
@@ -361,6 +367,7 @@ public final class TorManager: ObservableObject {
|
|||||||
public func shutdownCompletely() {
|
public func shutdownCompletely() {
|
||||||
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
SecureLogger.debug("TorManager: shutdownCompletely() called", category: .session)
|
||||||
startPendingAfterShutdown = false
|
startPendingAfterShutdown = false
|
||||||
|
bootstrapGeneration += 1
|
||||||
shutdownsInFlight += 1
|
shutdownsInFlight += 1
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -398,6 +405,7 @@ public final class TorManager: ObservableObject {
|
|||||||
SecureLogger.debug("TorManager: restartArti() starting", category: .session)
|
SecureLogger.debug("TorManager: restartArti() starting", category: .session)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
NotificationCenter.default.post(name: .TorWillRestart, object: nil)
|
NotificationCenter.default.post(name: .TorWillRestart, object: nil)
|
||||||
|
self.bootstrapGeneration += 1
|
||||||
self.isReady = false
|
self.isReady = false
|
||||||
self.socksReady = false
|
self.socksReady = false
|
||||||
self.bootstrapProgress = 0
|
self.bootstrapProgress = 0
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Foundation
|
|||||||
// Kept local until the test-helper module is split out.
|
// Kept local until the test-helper module is split out.
|
||||||
struct TestConstants {
|
struct TestConstants {
|
||||||
static let defaultTimeout: TimeInterval = 5.0
|
static let defaultTimeout: TimeInterval = 5.0
|
||||||
static let shortTimeout: TimeInterval = 1.0
|
|
||||||
static let longTimeout: TimeInterval = 10.0
|
static let longTimeout: TimeInterval = 10.0
|
||||||
|
|
||||||
static let testNickname1 = "Alice"
|
static let testNickname1 = "Alice"
|
||||||
|
|||||||
Reference in New Issue
Block a user