Close the final re-grade findings: parked EOSE fallback, panic atomicity, overflow visibility

EOSE callbacks parked while Tor is bootstrapping now get a fallback
unblock at the standard 10s EOSE timeout (via the injected scheduler,
generation-guarded, single-fire) instead of waiting up to ~225s for
Tor-readiness retry exhaustion. The identity swap in
refreshPeerIdentity runs inside a messageQueue barrier with re-entrancy
guard, so a panic reset can no longer race in-flight packet builds
(deadlock analysis documented; both call paths verified off-queue).
Relay send-queue overflow drops now log a sampled warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 21:29:08 +02:00
co-authored by Claude Fable 5
parent 1480b51c76
commit 0b007eee1a
5 changed files with 178 additions and 7 deletions
+41 -1
View File
@@ -198,6 +198,9 @@ final class NostrRelayManager: ObservableObject {
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
// Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0
private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
@@ -352,6 +355,18 @@ final class NostrRelayManager: ObservableObject {
messageQueue.removeFirst(overflow)
}
messageQueueLock.unlock()
guard overflow > 0 else { return }
// Dropped events are ephemeral (presence/geo), so no status surfacing
// is needed but the drops should be visible. Sampled so a sustained
// relay stall can't flood the log.
pendingSendDropCount += overflow
if pendingSendDropCount == 1 ||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
SecureLogger.warning(
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
category: .session
)
}
}
/// Try to flush any queued messages for relays that are now connected.
@@ -452,7 +467,7 @@ final class NostrRelayManager: ObservableObject {
if urls.isEmpty {
onEOSE()
} else if shouldWaitForTorBeforeConnecting {
pendingEOSECallbacks[id] = onEOSE
parkEOSECallbackUntilTorReady(id: id, callback: onEOSE)
} else {
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
}
@@ -623,6 +638,31 @@ final class NostrRelayManager: ObservableObject {
}
}
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
/// would only be unblocked by Tor-readiness retry exhaustion (several
/// awaitReady timeouts, i.e. minutes), leaving callers hanging far past the
/// normal EOSE fallback. If Tor recovers first the callback is promoted to
/// a real EOSE tracker (`startPendingEOSETrackingIfNeeded`), and if retry
/// exhaustion fires first it is drained by `unblockPendingEOSECallbacks`;
/// either way it leaves `pendingEOSECallbacks` and this timer is a no-op.
private func parkEOSECallbackUntilTorReady(id: String, callback: @escaping () -> Void) {
pendingEOSECallbacks[id] = callback
let generation = connectionGeneration
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
// Stale timers from a previous connection generation are void.
guard generation == self.connectionGeneration else { return }
// Already fired (unsubscribe, retry-exhaustion unblock) or
// promoted to a real EOSE tracker: nothing to do.
guard let callback = self.pendingEOSECallbacks.removeValue(forKey: id) else { return }
SecureLogger.warning("Unblocking Tor-parked EOSE callback for \(id) after fallback timeout", category: .session)
callback()
}
}
}
/// Fire and clear all EOSE callbacks that are parked waiting for Tor.
/// Callers treat EOSE as "initial fetch finished"; firing with no data is
/// safe and prevents indefinite hangs when Tor cannot bootstrap.
+24 -6
View File
@@ -74,6 +74,8 @@ final class BLEService: NSObject {
private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
/// Binary form of `myPeerID`; same contract mutated only inside a
/// `messageQueue` barrier via `refreshPeerIdentity()`.
private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy
@@ -407,8 +409,10 @@ final class BLEService: NSObject {
// MARK: Identity
/// Derived from the Noise identity fingerprint; rotated only via
/// `refreshPeerIdentity()` (e.g. panic reset). Externally read-only
/// no out-of-band mutation may bypass that derivation.
/// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
/// inside a `messageQueue` barrier so concurrent queue work never sees a
/// half-updated identity. Externally read-only no out-of-band mutation
/// may bypass that derivation.
private(set) var myPeerID = PeerID(str: "")
/// Externally read-only; mutate via `setNickname(_:)`, which also
/// broadcasts the change to peers.
@@ -2370,11 +2374,25 @@ extension BLEService {
}
}
/// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity.
/// The swap runs as a `messageQueue` barrier so in-flight work items that
/// read the identity (e.g. `sendMessage` building packets) complete
/// against the old value and everything after sees the new one atomically.
/// Callers (init, panic reset on the main thread) are never on
/// `messageQueue`; the re-entrancy check keeps any future on-queue caller
/// from deadlocking.
private func refreshPeerIdentity() {
let fingerprint = noiseService.getIdentityFingerprint()
myPeerID = PeerID(str: fingerprint.prefix(16))
myPeerIDData = Data(hexString: myPeerID.id) ?? Data()
meshTopology.reset()
let swap = {
let fingerprint = self.noiseService.getIdentityFingerprint()
self.myPeerID = PeerID(str: fingerprint.prefix(16))
self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
self.meshTopology.reset()
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
swap()
} else {
messageQueue.sync(flags: .barrier, execute: swap)
}
}
+3
View File
@@ -167,6 +167,9 @@ enum TransportConfig {
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10
// Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion
// eviction at the cap, plus an age sweep on connect attempts. Durable
// subscription intent survives in subscriptionRequestState either way.
+17
View File
@@ -164,6 +164,23 @@ struct BLEServiceCoreTests {
#expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b"))
}
@Test
func panicReset_rotatesPeerIDDerivedFromNewNoiseFingerprint() async throws {
let ble = makeService()
let originalPeerID = ble.myPeerID
let originalFingerprint = ble.noiseIdentityFingerprint()
#expect(originalPeerID == PeerID(str: originalFingerprint.prefix(16)))
ble.resetIdentityForPanic(currentNickname: "anon")
// The Noise identity is regenerated and the peer ID swaps with it
// (atomically, behind a messageQueue barrier).
let newFingerprint = ble.noiseIdentityFingerprint()
#expect(newFingerprint != originalFingerprint)
#expect(ble.myPeerID != originalPeerID)
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
}
@Test
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
@@ -133,6 +133,99 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
}
func test_subscribe_parkedEOSEFiresAfterFallbackTimeoutWhenTorNeverBecomesReady() async {
let relayURL = "wss://tor-eose-parked-fallback.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-parked-fallback",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
// Parking the callback schedules the normal EOSE fallback, not just
// the Tor retry-exhaustion unblock (~minutes later).
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrSubscriptionEOSEFallbackSeconds)
XCTAssertEqual(eoseCount, 0)
context.scheduler.runNext()
let unblocked = await waitUntil { eoseCount == 1 }
XCTAssertTrue(unblocked)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
// A later retry-exhaustion unblock must not fire the callback again.
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_subscribe_parkedEOSEFallbackIsNoOpWhenTorRecoversFirst() async throws {
let relayURL = "wss://tor-eose-parked-recover.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-parked-recover",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
// Tor recovers before the fallback fires; the callback is promoted to
// a real EOSE tracker when the subscription flushes.
context.torWaiter.resolve(true)
let subscribed = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscribed)
// The parked fallback (scheduled first) is now a no-op.
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 0)
// The real EOSE still completes the initial load exactly once...
try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-parked-recover")
let eoseCompleted = await waitUntil { eoseCount == 1 }
XCTAssertTrue(eoseCompleted)
// ...and the promoted tracker's own fallback does not double-fire.
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_subscribe_parkedEOSEFallbackIsNoOpAfterRetryExhaustionUnblock() async {
let relayURL = "wss://tor-eose-parked-exhausted.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
var eoseCount = 0
context.manager.subscribe(
filter: makeFilter(),
id: "tor-parked-exhausted",
relayUrls: [relayURL],
handler: { _ in },
onEOSE: { eoseCount += 1 }
)
// Retry exhaustion unblocks the parked callback first.
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
XCTAssertEqual(eoseCount, 1)
// The parked fallback finds nothing to do.
context.scheduler.runNext()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(eoseCount, 1)
}
func test_sendEvent_survivesFailedTorWaitAndSendsWhenTorRecovers() async throws {
let relayURL = "wss://tor-send-retry.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)