From ad5fb1ddc6e671cd5a0c72c9b9151fcf4fc17070 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 1 Jul 2026 23:57:29 +0200 Subject: [PATCH] Fail closed on unverified egress and gate the already-ready connect path Address two review findings on the Tor egress self-check: 1. Bypass on the already-bootstrapped path: shouldWaitForTorBeforeConnecting only checked torIsReady, so once Tor was up, connectToRelays/connectToRelay (initial connect, reconnect backoff timers, manual retry, subscription- triggered connects) opened relay WebSockets without ever running the egress canary. The gate now also requires a fresh cached egress verification (TorManager.isEgressVerified, backed by a nonisolated TTL snapshot on TorEgressVerifier); any path lacking one queues behind awaitEgressReady(). 2. unreachable canary no longer allows: an unreachable canary is exactly the ambiguous case where we cannot tell whether the platform honored the SOCKS proxy, so it now refuses relay connections (fail-closed) instead of allow-with-warning. A verifiedTor verdict still allows connection opens for its 300s TTL (a cached good verdict covers brief canary blips); after TTL expiry or invalidate() (Tor restart/dormant/shutdown) connections stay closed until a probe succeeds again. Probe cadence stays bounded via the verifier's minRetryInterval, and the relay manager schedules a bounded 30s-cadence gate retry after wait-attempt exhaustion so a transient canary outage recovers automatically (GeoRelayDirectory already retries with its own backoff). Tests: verifier policy (unreachable refuses, recovery via retry, cached- within-TTL allows during blip, TTL expiry + unreachable refuses, snapshot invalidation) and relay-manager gating (ready path awaits egress verification, reconnect requeues behind the gate, exhaustion schedules the bounded retry and recovers). Co-Authored-By: Claude Fable 5 --- bitchat/Nostr/NostrRelayManager.swift | 44 ++++++- bitchat/Services/TransportConfig.swift | 4 + .../Nostr/TorEgressVerifierTests.swift | 103 ++++++++++++++-- .../Services/NostrRelayManagerTests.swift | 115 ++++++++++++++++++ .../Arti/Sources/TorEgressVerifier.swift | 88 +++++++++++--- localPackages/Arti/Sources/TorManager.swift | 16 ++- 6 files changed, 335 insertions(+), 35 deletions(-) diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index b420b851..0c8084bd 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -61,6 +61,11 @@ struct NostrRelayManagerDependencies { var locationPermissionPublisher: AnyPublisher var torEnforced: () -> Bool var torIsReady: () -> Bool + /// Synchronous cached egress-gate check: `true` only while a positive Tor + /// egress verification is within its TTL (or Tor is not enforced). When + /// `false`, connections must be queued behind `awaitTorReady`, which runs + /// the async egress self-check. + var torEgressVerified: () -> Bool var torIsForeground: () -> Bool var awaitTorReady: (@escaping (Bool) -> Void) -> Void var makeSession: () -> NostrRelaySessionProtocol @@ -83,6 +88,7 @@ private extension NostrRelayManagerDependencies { locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(), torEnforced: { TorManager.shared.torEnforced }, torIsReady: { TorManager.shared.isReady }, + torEgressVerified: { TorManager.shared.isEgressVerified }, torIsForeground: { TorManager.shared.isForeground() }, awaitTorReady: { completion in Task.detached { @@ -628,8 +634,14 @@ final class NostrRelayManager: ObservableObject { // MARK: - Private Methods + /// Every path that opens a relay socket funnels through this check (initial + /// connect, reconnect backoff timers, subscription-triggered connects, + /// manual retry). It must hold connections back when Tor isn't bootstrapped + /// OR when the runtime egress self-check has no fresh positive verdict — + /// otherwise the already-bootstrapped path would open sockets without ever + /// running the egress canary. private var shouldWaitForTorBeforeConnecting: Bool { - shouldUseTor && !dependencies.torIsReady() + shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified()) } private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) { @@ -677,16 +689,20 @@ final class NostrRelayManager: ObservableObject { guard ready else { self.torReadyWaitAttempts += 1 if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts { - SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session) + SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session) self.queueConnectionsUntilTorReady(pending) } else { // Still fail-closed (no network), but unblock any callers // waiting on EOSE so the UI doesn't hang indefinitely. - // Queued subscriptions/sends are kept and flush if a later - // trigger (e.g. app foreground) brings Tor up. - SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session) + // Queued subscriptions/sends are kept; a bounded-cadence + // retry (below) re-enters the gate so a transient failure + // (Tor stall, canary outage keeping the egress unverified) + // recovers automatically, and any later trigger (e.g. app + // foreground) also re-enters it. + SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session) self.torReadyWaitAttempts = 0 self.unblockPendingEOSECallbacks(reason: "tor-unavailable") + self.scheduleTorGateRetry(pending) } return } @@ -696,6 +712,24 @@ final class NostrRelayManager: ObservableObject { } } + /// After the Tor-gate wait attempts are exhausted, keep a low-frequency + /// retry alive so the gate re-opens without an external trigger once the + /// transient failure clears. Bounded cadence: one wait cycle per + /// `nostrTorGateRetrySeconds`; the egress verifier additionally throttles + /// actual canary probes to one per its `minRetryInterval`. + private func scheduleTorGateRetry(_ relayUrls: [String]) { + guard !relayUrls.isEmpty else { return } + let generation = connectionGeneration + dependencies.scheduleAfter(TransportConfig.nostrTorGateRetrySeconds) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + // Void after disconnect/reset: those paths bump the generation. + guard generation == self.connectionGeneration else { return } + self.queueConnectionsUntilTorReady(relayUrls) + } + } + } + /// 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 diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 4aae7294..dbf6718c 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -171,6 +171,10 @@ enum TransportConfig { // How many consecutive Tor-readiness waits (each bounded by TorManager's // bootstrap deadline) to attempt before unblocking pending EOSE callers. static let nostrTorReadyMaxWaitAttempts: Int = 3 + // After Tor-gate wait attempts are exhausted (Tor never ready, or egress + // self-check unverified), retry the whole gate at this bounded cadence so + // a transient failure (e.g. canary outage) recovers without user action. + static let nostrTorGateRetrySeconds: TimeInterval = 30.0 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. diff --git a/bitchatTests/Nostr/TorEgressVerifierTests.swift b/bitchatTests/Nostr/TorEgressVerifierTests.swift index 65f65826..fd4c0202 100644 --- a/bitchatTests/Nostr/TorEgressVerifierTests.swift +++ b/bitchatTests/Nostr/TorEgressVerifierTests.swift @@ -83,36 +83,119 @@ struct TorEgressVerifierTests { #expect(await v.verify() == true) } - @Test("unreachable allows (session stays fail-closed) but does not cache a Tor verification") - func unreachableAllowsButNotCached() async { + @Test("unreachable refuses: an unverified egress must not proceed (fail-closed)") + func unreachableRefuses() async { let h = Harness() h.setResult(.unreachable("down")) let v = makeVerifier(h, ttl: 300, minRetry: 0) - #expect(await v.verify() == true) - // Not a verified state: once it turns into a real leak, we must refuse. - h.setResult(.notTor) #expect(await v.verify() == false) + #expect(v.hasFreshVerification == false) } - @Test("minRetryInterval throttles re-probing when not verified") + @Test("unreachable-then-reachable recovers via retry") + func unreachableRecoversWhenCanaryReturns() async { + let h = Harness() + h.setResult(.unreachable("down")) + let v = makeVerifier(h, ttl: 300, minRetry: 5) + + #expect(await v.verify() == false) + #expect(h.probeCount == 1) + + // Canary comes back; the next probe (after the retry throttle window) + // verifies and allows again. + h.setResult(.verifiedTor) + h.advance(5) + #expect(await v.verify() == true) + #expect(h.probeCount == 2) + #expect(v.hasFreshVerification == true) + } + + @Test("cached verifiedTor within TTL allows during a canary blip without re-probing") + func cachedVerifiedAllowsDuringCanaryBlip() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300, minRetry: 0) + + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + + // The canary goes down inside the TTL window: the cached positive + // verdict is authoritative, no probe runs, traffic stays allowed. + h.setResult(.unreachable("down")) + h.advance(100) + #expect(await v.verify() == true) + #expect(h.probeCount == 1) + #expect(v.hasFreshVerification == true) + } + + @Test("TTL expiry + unreachable refuses until a probe succeeds again") + func expiredCacheWithUnreachableRefuses() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300, minRetry: 0) + + #expect(await v.verify() == true) + + // Past the TTL the old verdict no longer stands: an unreachable canary + // means unverified egress, so connection opens are refused. + h.setResult(.unreachable("down")) + h.advance(301) + #expect(v.hasFreshVerification == false) + #expect(await v.verify() == false) + #expect(h.probeCount == 2) + + // A subsequent successful probe restores service. + h.setResult(.verifiedTor) + #expect(await v.verify() == true) + #expect(v.hasFreshVerification == true) + } + + @Test("minRetryInterval bounds re-probing while unverified (no canary hammering)") func throttleReprobe() async { let h = Harness() h.setResult(.unreachable("down")) let v = makeVerifier(h, ttl: 300, minRetry: 5) - #expect(await v.verify() == true) + #expect(await v.verify() == false) #expect(h.probeCount == 1) - // Within minRetry window: reuse last decision, no new probe. + // Within minRetry window: reuse last (refusing) decision, no new probe. h.advance(1) - #expect(await v.verify() == true) + #expect(await v.verify() == false) #expect(h.probeCount == 1) // After the window: re-probe. h.advance(5) - #expect(await v.verify() == true) + #expect(await v.verify() == false) #expect(h.probeCount == 2) } + @Test("invalidate clears the synchronous cache snapshot") + func invalidateClearsSnapshot() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300) + + #expect(await v.verify() == true) + #expect(v.hasFreshVerification == true) + await v.invalidate() + #expect(v.hasFreshVerification == false) + } + + @Test("notTor drops any cached verification snapshot") + func notTorClearsSnapshot() async { + let h = Harness() + h.setResult(.verifiedTor) + let v = makeVerifier(h, ttl: 300, minRetry: 0) + + #expect(await v.verify() == true) + #expect(v.hasFreshVerification == true) + + h.setResult(.notTor) + h.advance(301) + #expect(await v.verify() == false) + #expect(v.hasFreshVerification == false) + } + @Test("invalidate forces a fresh probe") func invalidateForcesReprobe() async { let h = Harness() diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index fbb46060..e94d78dc 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -111,6 +111,116 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(connected) } + func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async { + // Tor is bootstrapped, but the egress self-check has no fresh verdict: + // the ready path must still queue behind the async egress gate instead + // of opening sockets directly. + let context = makeContext( + permission: .authorized, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torEgressVerified: false + ) + + context.manager.connect() + + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + XCTAssertEqual(context.torWaiter.awaitCallCount, 1) + + // Egress verification succeeds (awaitEgressReady returned true, which + // implies the verifier now holds a fresh cached verdict). + context.torEgressVerified.value = true + context.torWaiter.resolve(true) + + let connectedAfterVerification = await waitUntil { + context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connectedAfterVerification) + } + + func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async { + // Reconnect backoff timers call connectToRelay directly; when the + // cached egress verification has lapsed by then, the reconnect must go + // back through the gate rather than opening a socket. + let relayURL = "wss://egress-reconnect.example" + let context = makeContext( + permission: .denied, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torEgressVerified: true + ) + + context.manager.ensureConnections(to: [relayURL]) + let connected = await waitUntil { + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(connected) + XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1) + + // The socket drops and the cached verification expires meanwhile. + context.torEgressVerified.value = false + context.sessionFactory.latestConnection(for: relayURL)? + .fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)) + let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 } + XCTAssertTrue(reconnectScheduled) + + context.scheduler.runNext() + let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 } + XCTAssertTrue(queuedBehindGate) + // No new socket until the egress gate passes. + XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1) + + context.torEgressVerified.value = true + context.torWaiter.resolve(true) + + let reconnected = await waitUntil { + context.sessionFactory.requestedURLs.count == 2 && + context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true + } + XCTAssertTrue(reconnected) + } + + func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async { + // A persistent unverified egress exhausts the wait attempts; a bounded + // low-frequency retry must then recover automatically once + // verification succeeds (e.g. transient canary outage ends). + let relayURL = "wss://egress-gate-retry.example" + let context = makeContext( + permission: .denied, + userTorEnabled: true, + torEnforced: true, + torIsReady: true, + torEgressVerified: false + ) + + context.manager.ensureConnections(to: [relayURL]) + for _ in 0.. Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter) ) -> RelayManagerTestContext { @@ -1458,6 +1569,7 @@ final class NostrRelayManagerTests: XCTestCase { let scheduler = MockRelayScheduler() let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000)) let torWaiter = MockTorWaiter(isReady: torIsReady) + let torEgressVerifiedFlag = MutableBool(value: torEgressVerified) let torForeground = MutableBool(value: torIsForeground) let activationFlag = MutableBool(value: activationAllowed) let manager = NostrRelayManager( @@ -1470,6 +1582,7 @@ final class NostrRelayManagerTests: XCTestCase { locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(), torEnforced: { torEnforced }, torIsReady: { torWaiter.isReady }, + torEgressVerified: { torEgressVerifiedFlag.value }, torIsForeground: { torForeground.value }, awaitTorReady: torWaiter.await(completion:), makeSession: { sessionFactory }, @@ -1489,6 +1602,7 @@ final class NostrRelayManagerTests: XCTestCase { clock: clock, activationAllowed: activationFlag, torWaiter: torWaiter, + torEgressVerified: torEgressVerifiedFlag, torForeground: torForeground ) } @@ -1543,6 +1657,7 @@ private struct RelayManagerTestContext { let clock: MutableClock let activationAllowed: MutableBool let torWaiter: MockTorWaiter + let torEgressVerified: MutableBool let torForeground: MutableBool } diff --git a/localPackages/Arti/Sources/TorEgressVerifier.swift b/localPackages/Arti/Sources/TorEgressVerifier.swift index 501573c0..b8645be5 100644 --- a/localPackages/Arti/Sources/TorEgressVerifier.swift +++ b/localPackages/Arti/Sources/TorEgressVerifier.swift @@ -15,16 +15,34 @@ import Foundation /// connections are opened under enforced Tor, it performs a canary request whose /// response positively reports whether the egress hit the network via Tor. /// -/// Policy (`verify()` return value): +/// Policy (`verify()` return value) — fail-closed on unverified egress: /// - `.verifiedTor` → allow, and cache the positive result for `ttl`. -/// - `.notTor` → REFUSE. The canary reached the internet but the exit -/// is NOT a Tor node: a real leak. Never allow relays. -/// - `.unreachable` → allow-with-warning. The canary itself failed (endpoint -/// down / circuit not built). This does NOT egress direct: -/// the relay session is independently fail-closed, so the -/// worst case is that relay connections also fail. We do -/// not hard-block here to avoid taking Nostr fully offline -/// whenever the single canary host is unavailable. +/// - `.notTor` → REFUSE, and drop any cached verification. The canary +/// reached the internet but the exit is NOT a Tor node: +/// a real leak. Never allow relays. +/// - `.unreachable` → REFUSE (egress unverified). The canary itself failed +/// (endpoint down / circuit not built), so we cannot tell +/// whether the platform honored the SOCKS proxy — the +/// exact ambiguity this verifier exists to resolve. +/// Unverified traffic must not proceed on the +/// enforced-Tor path. +/// +/// TTL / retry semantics: +/// - A `verifiedTor` verdict allows connection *opens* for `ttl` without +/// re-probing, so a brief canary blip inside the TTL window does not take +/// relays offline (a fresh positive verdict is authoritative for the +/// window). Already-open sockets are never torn down by verification — +/// they were opened under a verified egress and the proxied session is +/// fail-closed by construction. +/// - After TTL expiry (or `invalidate()` on Tor restart/dormant/shutdown), +/// the next `verify()` re-probes; while the canary stays `.unreachable`, +/// new connection opens are refused until a probe succeeds again. +/// - Probe cadence is bounded: at most one probe per `minRetryInterval` +/// (callers within the window reuse the last decision), and concurrent +/// `verify()` calls share one in-flight probe. Recovery from a transient +/// canary outage is automatic: callers that keep retrying (relay connect +/// gate, GeoRelayDirectory backoff) re-probe and succeed once the canary +/// is reachable again. /// /// The probe is injectable so the policy/caching logic is unit-tested without a /// live network (see `TorEgressVerifierTests`). @@ -51,6 +69,29 @@ public actor TorEgressVerifier { private var lastResult: ProbeResult? private var inFlight: Task? + /// Lock-protected mirror of "verified within TTL" so synchronous gates + /// (e.g. `NostrRelayManager`'s connect path) can consult the cache without + /// awaiting the actor. + private let verifiedSnapshot = VerifiedSnapshot() + + private final class VerifiedSnapshot: @unchecked Sendable { + private let lock = NSLock() + private var verifiedUntil: Date? + + func update(_ until: Date?) { + lock.lock() + verifiedUntil = until + lock.unlock() + } + + func isFresh(at date: Date) -> Bool { + lock.lock() + defer { lock.unlock() } + guard let verifiedUntil else { return false } + return date < verifiedUntil + } + } + public init( ttl: TimeInterval, minRetryInterval: TimeInterval = 5.0, @@ -69,14 +110,23 @@ public actor TorEgressVerifier { lastVerifiedAt = nil lastProbeAt = nil lastResult = nil + verifiedSnapshot.update(nil) } /// The most recent probe outcome, for diagnostics/tests. public func lastProbeResult() -> ProbeResult? { lastResult } - /// Returns `true` when it is safe to open relay connections through the - /// proxied session, `false` only when a non-Tor egress was positively - /// detected. See the type doc for the full policy. + /// Synchronous view of the cache: `true` while a `verifiedTor` verdict is + /// within its TTL. Callers that get `false` must route through the async + /// `verify()` gate (which probes) before opening connections. + public nonisolated var hasFreshVerification: Bool { + verifiedSnapshot.isFresh(at: now()) + } + + /// Returns `true` only when the proxied egress is verified to exit via Tor + /// (a fresh probe or a cached `verifiedTor` verdict within TTL). Returns + /// `false` when a non-Tor egress was positively detected *or* when the + /// egress could not be verified. See the type doc for the full policy. public func verify() async -> Bool { if isFreshlyVerified() { return true } // Throttle re-probes when the last attempt did not verify. @@ -102,8 +152,9 @@ public actor TorEgressVerifier { private func decision(for result: ProbeResult) -> Bool { switch result { case .verifiedTor: return true - case .unreachable: return true - case .notTor: return false + // Fail closed: both a positively detected leak and an unverifiable + // egress refuse connections. Only a fresh `verifiedTor` allows. + case .unreachable, .notTor: return false } } @@ -114,21 +165,24 @@ public actor TorEgressVerifier { switch result { case .verifiedTor: lastVerifiedAt = now() + verifiedSnapshot.update(now().addingTimeInterval(ttl)) return true case .notTor: lastVerifiedAt = nil + verifiedSnapshot.update(nil) SecureLogger.error( "🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)", category: .session ) return false case .unreachable(let why): - lastVerifiedAt = nil + // Note: a probe only runs when no fresh cached verdict exists, so + // there is no still-valid cache to preserve or drop here. SecureLogger.warning( - "🧅 Tor egress self-check could not complete (\(why)); relay session remains fail-closed via SOCKS proxy", + "🧅 Tor egress self-check could not complete (\(why)) — egress UNVERIFIED; refusing relay connections until the canary succeeds (bounded retry)", category: .session ) - return true + return false } } } diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index 288fea35..da3ab845 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -133,11 +133,21 @@ public final class TorManager: ObservableObject { return await MainActor.run(body: { self.networkPermitted }) } + /// Synchronous, cached view of the egress gate: `true` while a positive + /// egress verification is within its TTL (or when Tor is not enforced). + /// When this is `false`, callers must route through `awaitEgressReady()` + /// (which probes) before opening any connection — never connect directly. + public var isEgressVerified: Bool { + guard torEnforced else { return true } + return egressVerifier.hasFreshVerification + } + /// Like `awaitReady`, but additionally requires that a canary request /// through the proxied session positively verifies Tor egress. Returns - /// `false` if Tor never became ready, or if the egress self-check - /// positively detected a non-Tor (direct) egress. Callers must fail closed - /// on `false` — never fall back to a direct connection. + /// `false` if Tor never became ready, or if the egress self-check could + /// not positively verify a Tor exit (non-Tor egress detected, or canary + /// unreachable → unverified). Callers must fail closed on `false` — never + /// fall back to a direct connection. nonisolated public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool { let ready = await awaitReady(timeout: timeout)