Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions .claude/plans/2026-10-06-grpc-reresolve-on-path-change.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions Flipcash/Core/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -42,6 +43,7 @@ class Container {
self.notificationController = NotificationController()
self.databaseStore = DatabaseStore()
self.toasts = ToastController()
self.networkPath = NetworkPathMonitor()

_ = sessionAuthenticator
}
Expand All @@ -56,6 +58,7 @@ class Container {
.environment(preferences)
.environment(notificationController)
.environment(toasts)
.environment(networkPath)
}

static func configureFirebase() {
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Controllers/Deep Links/DeepLinkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
138 changes: 138 additions & 0 deletions Flipcash/Core/Controllers/NetworkPathMonitor.swift
Original file line number Diff line number Diff line change
@@ -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)",
])
}
}
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
))
}
}

Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Main/Tips/TipFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Send/SendAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
))
}
}
8 changes: 7 additions & 1 deletion Flipcash/Core/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
25 changes: 25 additions & 0 deletions Flipcash/UI/DialogItem+Connection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading