diff --git a/.claude/docs/architecture.md b/.claude/docs/architecture.md index 31ba90575..1ea401e8f 100644 --- a/.claude/docs/architecture.md +++ b/.claude/docs/architecture.md @@ -40,6 +40,7 @@ A gRPC transport failure (request timeout / unavailable channel) is a network co - **Typed error enums:** conform the enum to `TransportClassifiableError` — the compiler then requires all four transport cases (`.transportFailure` suppressed, `.cancelled` info, `.rejected` error, `.unknown` error) and the exhaustive `reportingLevel` forces you to classify them. In the call's `catch` map via `ErrorX.from(transportError: rpcError)`; the single shared default routes deterministic refusals (auth, precondition, bad argument) to `.rejected` and anomalies to `.unknown`. Retry loops gate on the shared `isRetryable` (`.unknown`/`.transportFailure` only) — never hand-roll the classification per call site, and never map a timeout to an `.error`-level `.unknown`. - **Associated-value error enums** (cases like `.grpcStatus(RPCError)` / `.network(Error)`): return `.suppressed` from `reportingLevel` when the captured error is transient (`rpcError.code.isTransientNetworkError`), and forward `.grpcStatus(s)` / `.network(e)` to the inner value's `reportingLevel` (`(error as? ServerError)?.reportingLevel ?? .error`), mirroring `ErrorSubmitIntent` / `ErrorSwap` / `ErrorStatelessSwap` / `ErrorModeration`. - **Unary RPCs whose failure type is the existential `Error`:** `RPCError` itself conforms to `ServerError`, mapping transient codes (`isTransientNetworkError`) to `.suppressed`, `.cancelled` to `.info` (app-initiated teardown, not a defect), and all other codes to `.error`, so shipping the raw error via `completion(.failure(error))` classifies transient transport failures automatically — no per-call-site mapping needed. +- **User-facing "check your connection" dialogs** go through `session.showConnectionFailure(_:)` (or `DialogItem.connectionFailure(_:path:)` for a screen-local dialog). If iOS reports the app's Cellular Data switch as off, it replaces the generic copy with a link to Settings. iOS fails those requests with `ENETDOWN` while the status bar still shows 5G. `NetworkPathMonitor` (on `Container`) logs every path change under `flipcash.network-path`. - `FlipcashCoreTests/TransportClassificationTests` asserts every conformer is wired — add ONE registry line when you add a classifiable error; it pins classification, severity parity, and retryability together. ## Navigation: AppRouter diff --git a/.claude/plans/2026-10-06-grpc-reresolve-on-path-change.md b/.claude/plans/2026-10-06-grpc-reresolve-on-path-change.md new file mode 100644 index 000000000..9f1b3c4d0 --- /dev/null +++ b/.claude/plans/2026-10-06-grpc-reresolve-on-path-change.md @@ -0,0 +1,101 @@ +# Re-resolve gRPC addresses when the network path changes + +**Status (2026-10-06):** implemented, uncommitted. Unit tests pass +(`NetworkChangeDNSResolverTests`, `NetworkPathStateTests`, `GRPCTransportTests`); the simulator +connects through the new resolver. The Wi-Fi to cellular path is not yet checked on a device. + +Changes from the plan below, after review: +- Push mode turns off the library's re-resolve after GOAWAY or a dropped connection + (`GRPCChannel.swift:147-148` leaves `resolverWithBackoff` nil, so `:655` is a no-op). To cover + server address rotation, the resolver also refreshes every 5 minutes and `AppDelegate` posts a + trigger on every foreground. +- A failed triggered lookup walks the retry delays once instead of giving up. +- Logs carry the resolved addresses, not just counts. + +## Problem + +A user on 5G saw every request fail with `POSIXErrorCode(rawValue: 50): Network is down` until +they force-quit the app. Cellular Data was on and no VPN was active. + +- The log names one address for a week: `[ipv4]3.221.240.75:443`, from 2026-09-30 12:33 to + 2026-10-06 17:35 (~56 lines). The process was suspended and resumed, never killed. +- grpc-swift-nio-transport 2.4.4 resolves `.dns(host:port:)` once with `getaddrinfo` and hands IP + literals to Network.framework. It re-resolves only when an *established* connection gets GOAWAY + or closes uncleanly (`Subchannel.swift:340`, `:357`). A failed connect backs off and redials + the same addresses forever (`Subchannel.swift:295`). +- Backgrounding does not help: `FlipClient` and `Client` are process-lifetime `let`s on + `Container` (`Container.swift:38-39`). Force-quit is the only thing that forces a fresh lookup. + +Unproven: why that address returns ENETDOWN on cellular. Leading guess: it was resolved on Wi-Fi +and the carrier's 5G is IPv6-only. The `ipv4`/`ipv6`/`dns` fields now logged by +`NetworkPathMonitor` will confirm or rule this out on the next export. + +## Approach: a push-mode DNS resolver + +`HTTP2ClientTransport.TransportServices.init` takes a `resolverRegistry` +(`HTTP2ClientTransport+TransportServices.swift:83-91`). `NameResolver` supports `.push` update +mode: the channel consumes an async sequence of `NameResolutionResult`s for as long as it lives and +hands each one to the load balancer (`GRPCChannel.swift:452-464`). + +Register a resolver for the DNS target that: + +1. Resolves with `getaddrinfo` on start and yields the result (same as today). +2. Re-resolves and yields again whenever it receives a "path changed" signal. +3. Also re-resolves when the channel reports a connect failure? Not possible from outside: push + resolvers get no `requiresNameResolution` callback. Path changes are the trigger. + +The default load balancer is pick-first (`GRPCChannel.swift:518`). On a new endpoint it replaces +the subchannel; on an identical endpoint it does nothing (`PickFirstLoadBalancer.swift:324`). So a +new address set reconnects, and an unchanged one costs nothing. + +Why this over rebuilding the clients: every service wrapper captures `GRPCClient` by value in a +`let` (e.g. `EventStreamingService.swift:17-20`), and `EventStreamer` / `LiveMintDataStreamer` +hold their service in a `let`. Swapping clients means making ~23 services, both streamers, and +their owners swappable. The resolver keeps all of that untouched. + +## Changes + +1. **FlipcashCore `PathAwareDNSResolver`** (new, next to `GRPCTransport.swift`): a + `NameResolverFactory` for `ResolvableTargets.DNS` returning `updateMode: .push`. It owns an + `AsyncStream` of re-resolve triggers. On each trigger it calls `getaddrinfo` off the + cooperative pool (the library's `DNSResolver` is `package`, so we need our own small wrapper) + and yields the endpoints. A failed lookup is logged and skipped, not thrown, so the channel + keeps its last good addresses. Throwing would end the sequence. +2. **A process-wide trigger** that `NetworkPathMonitor` fires on a change of `status` or + `interfaces`, not on every field (expensive/constrained flips shouldn't reconnect). FlipcashCore + can't see the app target, so the trigger lives in FlipcashCore (e.g. a + `NetworkPathChanges` broadcaster) and the app's monitor posts into it. +3. **`GRPCTransport.makeTransportServices`** passes a registry containing the new factory. + All four call sites pick it up with no change. The notification extensions' + `ChatNotificationClient` instances are short-lived and also unaffected in practice. +4. **Logging:** log each re-resolve with host, address count and families (not the IPs as message + text; addresses go in metadata), so the next export shows the address swap. + +## Tests + +- Resolver unit test with an injected lookup function: initial yield, a trigger yields new + endpoints, a failed lookup yields nothing and the sequence stays open, cancellation ends it. +- Monitor test: interface change posts a trigger; an expensive-only change does not. +- Device check: connect on Wi-Fi, background, switch to cellular, foreground. The log should show + a path change, a re-resolve, then `Event stream went live`. + +## Risks + +- Re-resolving on every Wi-Fi/cellular flip reconnects even when the old address would have + worked. Pick-first ignores identical results, so the cost is one lookup per change. +- If the next log shows the *same* addresses failing after a fresh lookup, this theory is wrong and + the fix is the bigger one: rebuild `FlipClient`/`Client` internals on path change. +- Upstream may fix this (re-resolve on connect failure). Worth filing an issue on + grpc/grpc-swift-nio-transport; this resolver can be deleted if they do. + +## Status: reverted (2026-10-06) + +Implemented, device-tested, then reverted. On bmc-iphone the Wi-Fi to cellular switch re-resolved and +reconnected within a second, but every lookup on both networks returned the same three `fc-v2` +addresses, including `3.221.240.75`, which worked over cellular. The address the user was stuck on +was not stale, so this fix does not address their bug. Side effect seen: DNS reorders the list, +pick-first treats a reordered list as new, so every lookup reconnected. + +Kept: `NetworkPathMonitor` logging (status, reason, interfaces, IPv4/IPv6/DNS) and the +cellular-denied dialog. Next step: wait for a report with that logging. Fallback if it recurs with a +satisfied path: rebuild the gRPC clients after repeated ENETDOWN (the force-quit equivalent). diff --git a/Flipcash/Core/Container.swift b/Flipcash/Core/Container.swift index a2cd67745..d520f8fe6 100644 --- a/Flipcash/Core/Container.swift +++ b/Flipcash/Core/Container.swift @@ -21,6 +21,7 @@ class Container { let notificationController: NotificationController let databaseStore: DatabaseStore let toasts: ToastController + let networkPath: NetworkPathMonitor @ObservationIgnored lazy var sessionAuthenticator = SessionAuthenticator(container: self) @ObservationIgnored lazy var deepLinkController = DeepLinkController(sessionAuthenticator: sessionAuthenticator) @@ -42,6 +43,7 @@ class Container { self.notificationController = NotificationController() self.databaseStore = DatabaseStore() self.toasts = ToastController() + self.networkPath = NetworkPathMonitor() _ = sessionAuthenticator } @@ -56,6 +58,7 @@ class Container { .environment(preferences) .environment(notificationController) .environment(toasts) + .environment(networkPath) } static func configureFirebase() { diff --git a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift index 089cee138..9a545e6f9 100644 --- a/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift +++ b/Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift @@ -325,10 +325,10 @@ struct DeepLinkAction { } private static func showLookupFailed(isScan: Bool, on session: Session) { - session.dialogItem = .error( + session.showConnectionFailure(.error( title: isScan ? "Couldn't Open Profile Card" : "Couldn't Open Profile", subtitle: "Please check your connection and try again" - ) + )) } /// Looks a person up through the same memo a person card or a tapped mention uses. diff --git a/Flipcash/Core/Controllers/NetworkPathMonitor.swift b/Flipcash/Core/Controllers/NetworkPathMonitor.swift new file mode 100644 index 000000000..eb32180da --- /dev/null +++ b/Flipcash/Core/Controllers/NetworkPathMonitor.swift @@ -0,0 +1,138 @@ +// +// NetworkPathMonitor.swift +// Flipcash +// + +import Foundation +import Network +import FlipcashCore + +nonisolated private let logger = Logger(label: "flipcash.network-path") + +/// What iOS reports about this app's route to the network. +struct NetworkPathState: Equatable, Sendable { + + /// Whether iOS will let the app open a connection. + enum Status: String, Sendable { + case satisfied + case unsatisfied + case requiresConnection + } + + /// Why iOS refuses the app a connection while the device itself may be online. + enum UnsatisfiedReason: String, Sendable { + case notAvailable + case cellularDenied + case wifiDenied + case localNetworkDenied + case vpnInactive + case unknown + } + + let status: Status + let unsatisfiedReason: UnsatisfiedReason? + let interfaces: [String] + let isExpensive: Bool + let isConstrained: Bool + let supportsIPv4: Bool + let supportsIPv6: Bool + let supportsDNS: Bool + + /// Whether the app's Cellular Data switch in Settings is what keeps it offline. + var isCellularDenied: Bool { + status != .satisfied && unsatisfiedReason == .cellularDenied + } + + /// The state assumed before iOS delivers the first path: online, so no dialog blames Settings + /// before iOS has reported anything. + static let unknown = NetworkPathState( + status: .satisfied, + unsatisfiedReason: nil, + interfaces: [], + isExpensive: false, + isConstrained: false, + supportsIPv4: true, + supportsIPv6: true, + supportsDNS: true + ) +} + +extension NetworkPathState { + + nonisolated init(_ path: NWPath) { + let status: Status = switch path.status { + case .satisfied: .satisfied + case .unsatisfied: .unsatisfied + case .requiresConnection: .requiresConnection + @unknown default: .unsatisfied + } + + let reason: UnsatisfiedReason? = switch path.unsatisfiedReason { + case .notAvailable: status == .satisfied ? nil : .notAvailable + case .cellularDenied: .cellularDenied + case .wifiDenied: .wifiDenied + case .localNetworkDenied: .localNetworkDenied + case .vpnInactive: .vpnInactive + @unknown default: .unknown + } + + let interfaces = path.availableInterfaces.map { interface in + switch interface.type { + case .wifi: "wifi" + case .cellular: "cellular" + case .wiredEthernet: "wired" + case .loopback: "loopback" + case .other: "other" + @unknown default: "unknown" + } + } + + self.init( + status: status, + unsatisfiedReason: reason, + interfaces: interfaces, + isExpensive: path.isExpensive, + isConstrained: path.isConstrained, + supportsIPv4: path.supportsIPv4, + supportsIPv6: path.supportsIPv6, + supportsDNS: path.supportsDNS + ) + } +} + +/// Tracks the app's network path and logs every change, so an exported log shows why iOS +/// reported the network as down. It lives as long as the process and is never cancelled. +@MainActor +@Observable +final class NetworkPathMonitor { + + /// The most recent path iOS delivered. + private(set) var state: NetworkPathState = .unknown + + @ObservationIgnored private let monitor = NWPathMonitor() + + init() { + monitor.pathUpdateHandler = { [weak self] path in + let state = NetworkPathState(path) + Task { @MainActor in + self?.update(state) + } + } + monitor.start(queue: DispatchQueue(label: "com.flipcash.network-path")) + } + + private func update(_ newState: NetworkPathState) { + guard newState != state else { return } + state = newState + logger.info("Network path changed", metadata: [ + "status": "\(newState.status.rawValue)", + "reason": "\(newState.unsatisfiedReason?.rawValue ?? "none")", + "interfaces": "\(newState.interfaces.joined(separator: ","))", + "expensive": "\(newState.isExpensive)", + "constrained": "\(newState.isConstrained)", + "ipv4": "\(newState.supportsIPv4)", + "ipv6": "\(newState.supportsIPv6)", + "dns": "\(newState.supportsDNS)", + ]) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f9967f06d..d99e6ad3f 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1303,10 +1303,10 @@ struct ConversationScreen: View { case .noSuchAccount: session.dialogItem = .info(title: "No Such Account", subtitle: "Nobody has claimed \(username.handle)") case .lookupFailed: - session.dialogItem = .error( + session.showConnectionFailure(.error( title: "Couldn't Open Profile", subtitle: "Please check your connection and try again" - ) + )) } } diff --git a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift index be912356b..f170cb95d 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift @@ -163,7 +163,7 @@ final class TipFlow { "error": "\(error)", ]) ErrorReporting.captureError(error, reason: "Failed to prepare tip recipient") - session.dialogItem = Self.failureDialog + session.showConnectionFailure(Self.failureDialog) } } } diff --git a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift index cf7df413a..b71a3eeda 100644 --- a/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift +++ b/Flipcash/Core/Screens/Main/Username/UsernameLookupScreen.swift @@ -202,7 +202,7 @@ struct UsernameLookupScreen: View { // `.suppressed`, so gating here would duplicate that judgement. ErrorReporting.captureError(error, reason: "Failed to look up username") - errorDialog = .usernameLookup(for: error) + errorDialog = .connectionFailure(.usernameLookup(for: error), path: container.networkPath.state) } } } diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index 6bfef51b7..106a04a77 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -708,9 +708,9 @@ final class SendAmountViewModel { } private func showResolveFailedError() { - session.dialogItem = .error( + session.showConnectionFailure(.error( title: "Couldn't Send", subtitle: "We couldn't reach the network. Please try again." - ) + )) } } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index f18b2f837..b6c2510c0 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -58,6 +58,12 @@ class Session { /// ``DialogWindow`` in a separate `UIWindow` above all sheets. var dialogItem: DialogItem? + /// Shows `fallback` for a request that never reached the server, or a dialog naming the + /// Cellular Data switch when that switch is what blocked the request. + func showConnectionFailure(_ fallback: DialogItem) { + dialogItem = .connectionFailure(fallback, path: container.networkPath.state) + } + /// Whether the standalone Bill Designer overlay is presented over /// the scan screen. Toggled by the Settings → Advanced Features row. var isShowingBillDesigner: Bool = false @@ -1813,7 +1819,7 @@ class Session { ) if error is ErrorFetchBalance { - dialogItem = .error(title: "Unable to Find Cash", subtitle: "Please check your connection and try again") + showConnectionFailure(.error(title: "Unable to Find Cash", subtitle: "Please check your connection and try again")) } else { dialogItem = .error(title: "Something Went Wrong", subtitle: "Please try again later") } diff --git a/Flipcash/UI/DialogItem+Connection.swift b/Flipcash/UI/DialogItem+Connection.swift new file mode 100644 index 000000000..f1986403d --- /dev/null +++ b/Flipcash/UI/DialogItem+Connection.swift @@ -0,0 +1,25 @@ +// +// DialogItem+Connection.swift +// Flipcash +// + +import Foundation +import FlipcashUI + +extension DialogItem { + + /// Returns a dialog for a request that never reached the server. If the app's Cellular Data + /// switch is off, it says so and links to Settings. Otherwise it returns `fallback`. + static func connectionFailure(_ fallback: DialogItem, path: NetworkPathState) -> DialogItem { + guard path.isCellularDenied else { return fallback } + return .error( + title: fallback.title ?? "No Connection", + subtitle: "Cellular data is turned off for Flipcash. Turn it on in Settings to use Flipcash away from Wi-Fi." + ) { + .destructive("Open Settings") { + URL.openSettings() + }; + .dismiss(kind: .subtle) + } + } +} diff --git a/FlipcashTests/ConnectionFailureDialogTests.swift b/FlipcashTests/ConnectionFailureDialogTests.swift new file mode 100644 index 000000000..d114a771b --- /dev/null +++ b/FlipcashTests/ConnectionFailureDialogTests.swift @@ -0,0 +1,61 @@ +// +// ConnectionFailureDialogTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("Connection failure dialogs") +struct ConnectionFailureDialogTests { + + private let fallback = DialogItem.error( + title: "Couldn't Send", + subtitle: "We couldn't reach the network. Please try again." + ) + + private func path( + _ status: NetworkPathState.Status, + reason: NetworkPathState.UnsatisfiedReason? + ) -> NetworkPathState { + NetworkPathState( + status: status, + unsatisfiedReason: reason, + interfaces: [], + isExpensive: false, + isConstrained: false, + supportsIPv4: true, + supportsIPv6: true, + supportsDNS: true + ) + } + + @Test("A cellular-denied path names the switch and links to Settings") + func cellularDenied_pointsAtSettings() { + let item = DialogItem.connectionFailure(fallback, path: path(.unsatisfied, reason: .cellularDenied)) + #expect(item.title == "Couldn't Send") + #expect(item.subtitle?.contains("Cellular data is turned off for Flipcash") == true) + #expect(item.actions.map(\.title).first == "Open Settings") + } + + @Test("Any other unsatisfied path keeps the caller's dialog", arguments: [ + NetworkPathState.UnsatisfiedReason.notAvailable, + .wifiDenied, + .localNetworkDenied, + .vpnInactive, + .unknown, + ]) + func otherReasons_keepFallback(reason: NetworkPathState.UnsatisfiedReason) { + let item = DialogItem.connectionFailure(fallback, path: path(.unsatisfied, reason: reason)) + #expect(item.id == fallback.id) + } + + @Test("A satisfied path keeps the caller's dialog") + func satisfied_keepsFallback() { + let item = DialogItem.connectionFailure(fallback, path: .unknown) + #expect(item.id == fallback.id) + } +}