mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
Fix transport reliability gaps: Tor stalls, weak-signal sends, GCS input validation
NostrRelayManager no longer strands work when Tor is slow to bootstrap: failed readiness waits retry (bounded by nostrTorReadyMaxWaitAttempts) instead of dropping queued relay connections, parked EOSE callbacks fire after exhaustion so callers never hang, and sends made before Tor is ready are queued locally (capped) instead of being dropped on a failed wait - still strictly fail-closed. MessageRouter now prefers a connected transport over a merely window-reachable one, and sends made on a weak reachability signal are retained in the outbox until a delivery/read ack confirms receipt (receivers dedup by message ID), with resends bounded by attempt count. GCS sync filters from the wire are bounds-checked (p in 1...32, m > 1) at both the packet decode and filter decode layers; oversized Golomb parameters previously decoded to garbage via silent shift overflow. BLELinkStateStore is now explicitly pinned to bleQueue: debug builds trap any access from another queue, enforcing the ownership discipline the surrounding code already relied on by convention. CI gains an iOS simulator build job (arm64 only; the vendored Arti xcframework has no x86_64 simulator slice) so iOS-conditional code paths are compile-checked - SPM tests only cover the macOS slice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,4 +20,32 @@ struct GCSFilterTests {
|
||||
#expect(bucket != 0)
|
||||
#expect(bucket < 2)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsOutOfRangeParameters() {
|
||||
let junk = Data(repeating: 0xFF, count: 64)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 0, m: 1000, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: -1, m: 1000, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: GCSFilter.maxP + 1, m: 1000, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 255, m: UInt32.max, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 8, m: 0, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 8, m: 1, data: junk).isEmpty)
|
||||
}
|
||||
|
||||
@Test func decodeOfTruncatedDataReturnsOnlyCompleteValues() {
|
||||
let ids = (0..<32).map { i in Data(repeating: UInt8(i), count: 16) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||
let full = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||
let truncated = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data.prefix(params.data.count / 2))
|
||||
#expect(truncated.count <= full.count)
|
||||
// Truncation must not invent values that were not in the full set.
|
||||
#expect(truncated.allSatisfy { full.contains($0) })
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketDecodeRejectsOversizedP() {
|
||||
let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02]))
|
||||
#expect(RequestSyncPacket.decode(from: valid.encode()) != nil)
|
||||
|
||||
let oversized = RequestSyncPacket(p: 200, m: 4096, data: Data([0x01, 0x02]))
|
||||
#expect(RequestSyncPacket.decode(from: oversized.encode()) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,74 @@ struct MessageRouterTests {
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_prefersConnectedTransportOverEarlierReachableOne() async {
|
||||
let peerID = PeerID(str: "0000000000000005")
|
||||
let reachableOnly = MockTransport()
|
||||
reachableOnly.reachablePeers.insert(peerID)
|
||||
let connected = MockTransport()
|
||||
connected.connectedPeers.insert(peerID)
|
||||
connected.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [reachableOnly, connected])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m5")
|
||||
|
||||
#expect(reachableOnly.sentPrivateMessages.isEmpty)
|
||||
#expect(connected.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_reachableOnlySendRetainsUntilDeliveryAck() async {
|
||||
let peerID = PeerID(str: "0000000000000006")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m6")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// No ack yet: a flush retries over the weak signal.
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
// Ack clears the retained copy; later flushes stop resending.
|
||||
router.markDelivered("m6")
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSendIsNotRetained() async {
|
||||
let peerID = PeerID(str: "0000000000000007")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flushOutbox_dropsUnackedMessageAfterAttemptCap() async {
|
||||
let peerID = PeerID(str: "0000000000000008")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m8")
|
||||
|
||||
for _ in 0..<10 {
|
||||
router.flushOutbox(for: peerID)
|
||||
}
|
||||
|
||||
// Initial send plus capped resends; never unbounded.
|
||||
#expect(transport.sentPrivateMessages.count == 8)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendReadReceipt_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
|
||||
@@ -90,6 +90,90 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
}
|
||||
|
||||
func test_connect_retriesTorWaitAndConnectsWhenTorBecomesReady() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.connect()
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
|
||||
context.torWaiter.resolve(false)
|
||||
|
||||
// A failed wait re-queues the same targets and waits again instead of dropping them.
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 2)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
}
|
||||
|
||||
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
||||
let relayURL = "wss://tor-eose-unblock.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "tor-sub-unblock",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||
context.torWaiter.resolve(false)
|
||||
}
|
||||
|
||||
// Fail-closed (no sessions), but the EOSE caller is unblocked.
|
||||
XCTAssertEqual(eoseCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
}
|
||||
|
||||
func test_sendEvent_survivesFailedTorWaitAndSendsWhenTorRecovers() async throws {
|
||||
let relayURL = "wss://tor-send-retry.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let event = try makeSignedEvent(content: "queued through tor stall")
|
||||
|
||||
context.manager.sendEvent(event, to: [relayURL])
|
||||
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(false)
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let sent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
|
||||
context.manager.debugPendingMessageQueueCount == 0
|
||||
}
|
||||
XCTAssertTrue(sent)
|
||||
}
|
||||
|
||||
func test_sendEvent_pendingQueueDropsOldestBeyondCap() async throws {
|
||||
let relayURL = "wss://tor-send-cap.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
for i in 0...(TransportConfig.nostrPendingSendQueueCap + 4) {
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [],
|
||||
content: "cap-\(i)"
|
||||
)
|
||||
context.manager.sendEvent(try event.sign(with: identity.schnorrSigningKey()), to: [relayURL])
|
||||
}
|
||||
|
||||
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
|
||||
}
|
||||
|
||||
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -439,6 +523,114 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertEqual(receivedEvent?.id, event.id)
|
||||
}
|
||||
|
||||
func test_receiveEvent_deduplicatesSameSubscriptionEventAcrossRelays() async throws {
|
||||
let firstRelayURL = "wss://events-one.example"
|
||||
let secondRelayURL = "wss://events-two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let event = try makeSignedEvent(content: "duplicate")
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "events",
|
||||
relayUrls: [firstRelayURL, secondRelayURL]
|
||||
) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionsSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionsSent)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
|
||||
}
|
||||
|
||||
func test_receiveEvent_duplicateFanInDeliversOnceAndCountsDrops() async throws {
|
||||
let relayURLs = (0..<8).map { "wss://fan-in-\($0).example" }
|
||||
let context = makeContext(permission: .denied)
|
||||
let event = try makeSignedEvent(content: "fan-in")
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "presence",
|
||||
relayUrls: relayURLs
|
||||
) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionsSent = await waitUntil {
|
||||
relayURLs.allSatisfy { relayURL in
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(subscriptionsSent)
|
||||
|
||||
for relayURL in relayURLs {
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(
|
||||
subscriptionID: "presence",
|
||||
event: event
|
||||
)
|
||||
}
|
||||
|
||||
let countedOnEveryRelay = await waitUntil {
|
||||
relayURLs.allSatisfy { relayURL in
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(countedOnEveryRelay)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
|
||||
XCTAssertEqual(
|
||||
context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "presence"),
|
||||
relayURLs.count - 1
|
||||
)
|
||||
}
|
||||
|
||||
func test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache() async throws {
|
||||
let firstRelayURL = "wss://invalid-first-one.example"
|
||||
let secondRelayURL = "wss://invalid-first-two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let event = try makeSignedEvent(content: "valid-after-invalid")
|
||||
let invalidEvent = invalidSignatureCopy(of: event)
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "events",
|
||||
relayUrls: [firstRelayURL, secondRelayURL]
|
||||
) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionsSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionsSent)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
|
||||
}
|
||||
|
||||
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -880,6 +1072,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
return try event.sign(with: identity.schnorrSigningKey())
|
||||
}
|
||||
|
||||
private func invalidSignatureCopy(of event: NostrEvent) -> NostrEvent {
|
||||
var invalid = event
|
||||
invalid.sig = String(repeating: "0", count: 128)
|
||||
return invalid
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
|
||||
Reference in New Issue
Block a user