Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
5af5bee
test: reproduce irx mint failure on stale kept-alive broker connection
azooz2003-bit Aug 27, 2026
eed4383
irx: retry mint once on stale kept-alive connection, attribute URL er…
azooz2003-bit Aug 27, 2026
102728c
control-plane v1: JSON Schema wire contract + generated Swift/TS types
azooz2003-bit Aug 27, 2026
3206fb5
control-plane v1: proof optional on mint_request/publish_hint (phase …
azooz2003-bit Aug 27, 2026
ee15150
control-plane v1: relay passes carry refreshAfter
azooz2003-bit Aug 27, 2026
04a7325
irx control plane: socket client, pushed passes, event-driven relay r…
azooz2003-bit Aug 27, 2026
50ce4bb
Merge branch 'feat-irx-mint-retry' into feat-irx-ctlplane
azooz2003-bit Aug 27, 2026
c5284ec
control-plane v1: wire the account control-plane channel into the pre…
azooz2003-bit Aug 27, 2026
fa182b9
ci: guard the committed control-plane wire types in workflow-guard-tests
azooz2003-bit Aug 27, 2026
cff4b32
irx control plane: passes stay on the HTTPS autopilot in phase A (bro…
azooz2003-bit Aug 27, 2026
8359ce8
irx: Settings Networking reflects the irx runtime instead of the dorm…
azooz2003-bit Aug 27, 2026
7cbf8e3
irx control plane: Mac passes stay on the HTTPS autopilot in phase A too
azooz2003-bit Aug 27, 2026
ab46dc7
control plane: dev worker proxies upstream calls to the staging web d…
azooz2003-bit Aug 27, 2026
9b857c6
irx: register before minting on cached-state launches (mint proof race)
azooz2003-bit Aug 27, 2026
5f89aac
control plane: carry the Stack refresh token beside the bearer
azooz2003-bit Aug 27, 2026
4e26840
irx: provisioning is event-driven on sign-in, never before it
azooz2003-bit Aug 27, 2026
a61abe7
irx: publish device-registry routes to shared staging in Debug
azooz2003-bit Aug 28, 2026
20fef43
irx: provision restored sessions too, not only fresh sign-in publishes
azooz2003-bit Aug 28, 2026
7f7487c
irx: journal every auth-gate transition
azooz2003-bit Aug 28, 2026
265eadc
irx: physical-device attach tickets carry the Mac device id
azooz2003-bit Aug 28, 2026
994fc6e
irx: namespace transport state per bundle and broker; owner-only cach…
azooz2003-bit Aug 28, 2026
aecc7bc
Unbreak the macOS build: pass the namespaced state directory into Irx…
azooz2003-bit Aug 28, 2026
1b41f40
irx: pairing attempts clear the stack-token gate's timed-out suppression
azooz2003-bit Aug 28, 2026
7cba7e3
Merge remote-tracking branch 'origin/main' into feat-irx-ctlplane
azooz2003-bit Aug 28, 2026
1e5270c
auth: explicit pairing attempts supersede parked timed-out phases
azooz2003-bit Aug 28, 2026
6567a34
Merge remote-tracking branch 'origin/main' into feat-irx-ctlplane
azooz2003-bit Aug 28, 2026
161f2e0
Merge remote-tracking branch 'origin/main' into feat-irx-ctlplane
azooz2003-bit Aug 28, 2026
241f29d
Adopt main's vendor/bonsplit pin (merge kept stale gitlink)
azooz2003-bit Aug 28, 2026
238a38b
Merge remote-tracking branch 'origin/feat-irx-ctlplane' into feat-lis…
azooz2003-bit Aug 30, 2026
33b662e
listauth: acceptance-gates harness (30-min soak relay/direct, cold <2…
azooz2003-bit Aug 30, 2026
014778f
listauth backend: listv2 overlay, acks + alarm retry ladder, revocati…
azooz2003-bit Aug 30, 2026
8aae7fc
fix(main-red): expose processLiveness in AgentHibernationRecord membe…
azooz2003-bit Aug 30, 2026
80e9744
listauth gates: per-launch sim sign-in env, signed-in anchor, directo…
azooz2003-bit Aug 30, 2026
8537d1b
irx keepalive: two consecutive pong misses before death, immediate re…
azooz2003-bit Aug 30, 2026
3e04f82
irx: authorize NAT traversal after admission in automatic path mode
azooz2003-bit Aug 30, 2026
68c66c0
irx: foreground stale-redial — replace zombie sessions immediately on…
azooz2003-bit Aug 30, 2026
3a8b53b
listauth gates: background scoring uses foreground-relative wall delta
azooz2003-bit Aug 30, 2026
d2aed68
irx: send X-Cmux-App-Namespace on the control socket; DO emits confir…
azooz2003-bit Aug 31, 2026
14fcb37
mobile-dev-launch: allow cold auth bootstrap
azooz2003-bit Aug 31, 2026
b57438e
stabilize irx mobile sessions and presence heartbeats
azooz2003-bit Aug 31, 2026
4ae6995
fix presence control-plane directory discovery
azooz2003-bit Sep 1, 2026
60505d1
Merge origin/main into feat-listauth
azooz2003-bit Sep 1, 2026
ddfea62
test: remove timing assertion from control plane race
azooz2003-bit Sep 1, 2026
380ca8b
Merge remote-tracking branch 'origin/main' into feat-listauth
azooz2003-bit Sep 1, 2026
3717ddc
fix: keep control-plane and devbox CI contracts green
azooz2003-bit Sep 1, 2026
7e2f370
fix: harden redial and legacy directory compatibility
azooz2003-bit Sep 1, 2026
328b9a6
fix: recover zombie control sockets safely
azooz2003-bit Sep 1, 2026
fbd35fb
Merge remote-tracking branch 'origin/main' into feat-listauth
azooz2003-bit Sep 1, 2026
7155014
fix: preserve cache data and monotonic reconnect liveness
azooz2003-bit Sep 1, 2026
c8e37df
fix: reject replayed leases and unscoped cache migration
azooz2003-bit Sep 1, 2026
9a0a6b5
fix: acknowledge revisioned control-plane updates
azooz2003-bit Sep 1, 2026
afadcc4
fix: close reconnect and admission race windows
azooz2003-bit Sep 1, 2026
c52527b
fix: acknowledge directories and preserve legacy cache
azooz2003-bit Sep 1, 2026
39ac413
fix: serialize control loops and merge relay updates
azooz2003-bit Sep 1, 2026
9b94796
fix: acknowledge only applied control-plane facts
azooz2003-bit Sep 1, 2026
5bfad3c
fix: remove shared decoder and snapshot registry closes
azooz2003-bit Sep 1, 2026
dfe4185
fix: bound persisted device lease TTL
azooz2003-bit Sep 1, 2026
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ jobs:
working-directory: agent-chat
run: bun test/claude-environment.test.ts

# The committed control-plane wire types (Swift + TS) must be exactly
# what quicktype regenerates from schemas/control-plane/; fails on any
# hand edit or schema change without regen. Needs bun (bunx quicktype),
# set up above.
- name: Validate control-plane generated types
run: ./scripts/check-control-plane-types.sh

- name: Set up Python 3.9 for nightly prune compatibility
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,20 @@ public struct CmxPairingQRCode: Sendable {
guard let identity = encodableIrohIdentity(of: ticket) else {
return nil
}
items = [
var irohItems = [
"v=\(Self.irohVersion)",
"i=\(identity.endpointID)"
]
// The Mac device id rides along so the decoded ticket can name the
// peer intent (`expectedPeerDeviceID`) the irx transport requires
// before any dial. Endpoint-only tickets decode with an empty
// device id, and a fresh pairing then has no post-handshake source
// for it, so every injected physical-device auto-pair fails
// `missingPeerIntent` without this field.
if let macDeviceID = normalizedNonEmpty(ticket.macDeviceID) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an Iroh ticket has no non-empty macDeviceID, this branch emits a v3 URL that cannot pass transport admission because the decoded expectedPeerDeviceID is empty. Treat such tickets as not encodable and keep canEncode consistent so callers use the fallback payload instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingQRCode.swift, line 113:

<comment>When an Iroh ticket has no non-empty `macDeviceID`, this branch emits a v3 URL that cannot pass transport admission because the decoded `expectedPeerDeviceID` is empty. Treat such tickets as not encodable and keep `canEncode` consistent so callers use the fallback payload instead.</comment>

<file context>
@@ -100,10 +100,20 @@ public struct CmxPairingQRCode: Sendable {
+            // device id, and a fresh pairing then has no post-handshake source
+            // for it, so every injected physical-device auto-pair fails
+            // `missingPeerIntent` without this field.
+            if let macDeviceID = normalizedNonEmpty(ticket.macDeviceID) {
+                irohItems.append("d=\(percentEncodeQueryValue(macDeviceID))")
+            }
</file context>

irohItems.append("d=\(percentEncodeQueryValue(macDeviceID))")
}
items = irohItems
case .legacyPrivateNetworkCompatibility:
guard let routes = encodableTailscaleRoutes(of: ticket) else {
return nil
Expand Down Expand Up @@ -307,13 +317,19 @@ private extension CmxPairingQRCode {
/// Decode the v3 endpoint-only Iroh grammar.
func decodeIroh(_ components: URLComponents) throws -> CmxAttachTicket {
let items = components.queryItems ?? []
guard items.count == 2,
// `d` (the Mac device id) is optional so pre-existing endpoint-only
// URLs keep decoding; everything else stays exact-cardinality strict.
guard items.count <= 3,
items.allSatisfy({ ["v", "i", "d"].contains($0.name) }),
items.filter({ $0.name == "v" }).count == 1,
let endpointID = items.first(where: { $0.name == "i" })?.value,
items.filter({ $0.name == "i" }).count == 1,
items.filter({ $0.name == "d" }).count <= 1,
let identity = try? CmxIrohPeerIdentity(endpointID: endpointID) else {
throw MobileSyncPairingPayloadError.invalidURL
}
let macDeviceID = items.first(where: { $0.name == "d" })?.value?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let route = try CmxAttachRoute(
id: CmxAttachTransportKind.iroh.rawValue,
kind: .iroh,
Expand All @@ -323,7 +339,7 @@ private extension CmxPairingQRCode {
let ticket = try CmxAttachTicket(
workspaceID: "",
terminalID: nil,
macDeviceID: "",
macDeviceID: macDeviceID,
macDisplayName: nil,
// v3 is intentionally endpoint-only. `nil` means the QR did not
// make a compatibility claim, unlike v2's explicit unknown value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,16 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
routeDisclosureMode: .irohIdentityOnly,
pairingURLScheme: compactIrohQRTarget
))
// The Mac device id is deliberately part of the minimal grammar: the
// decoded ticket must name the peer intent (`expectedPeerDeviceID`) the
// irx transport requires before it will dial, and a fresh pairing has no
// other source for it. It identifies but never authorizes; admission
// stays the only authority.
#expect(
pairingURL
== "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)"
== "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)&d=mac-1"
)
#expect(!pairingURL.contains("payload="))
#expect(!pairingURL.contains("mac-1"))
#expect(!pairingURL.contains(privateAddress))
#expect(!pairingURL.contains(relayURL))
#expect(!pairingURL.contains(websocketURL))
Expand All @@ -118,7 +122,7 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
)
)
#expect(pairingDecoded.routes == [expectedPairingRoute])
#expect(pairingDecoded.macDeviceID.isEmpty)
#expect(pairingDecoded.macDeviceID == "mac-1")
#expect(pairingDecoded.macDisplayName == nil)
#expect(pairingDecoded.macUserID == nil)
// Endpoint-only v3 codes intentionally omit compatibility metadata. Keep
Expand Down Expand Up @@ -146,7 +150,10 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
)
#expect(pairingURL.utf8.count < beforeURL.utf8.count)
#expect(afterModules < beforeModules)
#expect(afterModules <= 41)
// 45 = one QR version above the endpoint-only 41: the `d` device-id field
// buys working irx peer intent for one version step, still far below the
// 57-module compact v1 payload.
#expect(afterModules <= 45)

let tailscaleOnly = try CmxAttachTicket(
workspaceID: "",
Expand All @@ -164,3 +171,22 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
)
}
}

@Test func identityOnlyQRDecodeToleratesLegacyURLsWithoutDeviceID() throws {
// Pre-`d` encoders mint exactly `v` + `i`. Those URLs must keep decoding
// (empty device id), and a duplicated or unknown parameter still fails.
let base = "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)"
let legacy = try #require(URLComponents(string: base))
let decoded = try CmxPairingQRCode().decode(legacy)
#expect(decoded.macDeviceID.isEmpty)
#expect(decoded.routes.count == 1)

let doubledDevice = try #require(URLComponents(string: base + "&d=a&d=b"))
#expect(throws: MobileSyncPairingPayloadError.invalidURL) {
_ = try CmxPairingQRCode().decode(doubledDevice)
}
let unknownParameter = try #require(URLComponents(string: base + "&x=1"))
#expect(throws: MobileSyncPairingPayloadError.invalidURL) {
_ = try CmxPairingQRCode().decode(unknownParameter)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ public final class AuthCoordinator {
await checkExistingSession()
}

/// Supersede parked timed-out auth phases before an explicit interactive
/// attempt (a pairing attempt, a tapped retry). One Stack call hung on a
/// dead pooled connection times its phase out and dampens it for 30s;
/// without this, the very next user action fails in milliseconds with
/// ``AuthError/timedOut`` even though a fresh request would succeed. The
/// timed-out operation was already cancelled at its deadline and its
/// writes are dropped by the sign-in chokepoint, so releasing its slot is
/// safe; live operations keep their exclusivity.
public func supersedeTimedOutAuthPhases() async {
// Both dampers: sign-in exchanges park in the phase registry, and
// token-touching work (access-token fetches, session probes) parks in
// the coordinator's own timed-out states. Token-touching phases allow
// concurrent actives by construction (write safety is generational,
// via finishTokenTouchingPhase), so dropping the damper alone is
// sufficient there.
await phaseTimeoutRegistry.supersedeTimedOutPhases()
timedOutTokenTouchingPhaseStates.removeAll()
}

// MARK: - Sign-in flows

/// Send a sign-in code to `email`, or run the debug `42` shortcut.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,22 @@ actor AuthPhaseTimeoutRegistry {
activePhases[key] = nil
timedOutPhases[key] = nil
}

/// Supersede every parked timed-out phase for an explicit interactive
/// attempt. A timed-out phase's operation was already cancelled at its
/// deadline and the sign-in write chokepoint drops a cancelled flow's
/// token writes, so the damper's only remaining job is suppressing
/// AUTOMATIC retry hammering; an explicit user action (a pairing attempt,
/// a tapped retry) is entitled to reclaim the phase immediately. Only the
/// timed-out operation's slot is released: a live, untimed-out operation
/// keeps refusing concurrent begins exactly as before.
func supersedeTimedOutPhases() {
for (key, state) in timedOutPhases {
activePhases[key]?.remove(state.id)
if activePhases[key]?.isEmpty == true {
activePhases[key] = nil
}
}
timedOutPhases.removeAll()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an operation is caller-cancelled, the deferred markTimedOut can run after this reset and re-arm the phase. Invalidate or synchronize pending timeout marks as part of supersession so the explicit retry cannot remain blocked by the cancelled operation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhaseTimeoutRegistry.swift, line 76:

<comment>When an operation is caller-cancelled, the deferred `markTimedOut` can run after this reset and re-arm the phase. Invalidate or synchronize pending timeout marks as part of supersession so the explicit retry cannot remain blocked by the cancelled operation.</comment>

<file context>
@@ -57,4 +57,22 @@ actor AuthPhaseTimeoutRegistry {
+                activePhases[key] = nil
+            }
+        }
+        timedOutPhases.removeAll()
+    }
 }
</file context>

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,37 @@ import Testing
await waitUntilTokenTouchingCleanupFinished(coordinator)
}

@Test func explicitSupersedeUnblocksTimedOutAccessTokenPhaseImmediately() async throws {
// One launch-time Stack call hung on a dead pooled connection arms
// the token-touching damper; a pairing attempt seconds later must be
// able to supersede it instead of fast-failing for the damper's
// remaining window.
let clock = ManualTestClock()
let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A")
let client = HangingLaunchTokenProbeAuthClient(user: user)
let coordinator = makeCoordinator(client: client, clock: clock)

let first = Task { try await coordinator.accessToken() }
await client.accessTokenDidStart()
await clock.waitUntilSleepers()
clock.advance(by: Self.testTimeouts.network)
await #expect(throws: AuthError.timedOut) { try await first.value }

// Damper armed (default 30s window): an automatic retry fast-fails.
let second = Task { try await coordinator.accessToken() }
await #expect(throws: AuthError.timedOut) { try await second.value }
#expect(await client.accessStartCount == 1)

// An explicit interactive attempt supersedes and runs a fresh probe.
await coordinator.supersedeTimedOutAuthPhases()
await client.releaseHangingAccessTokenProbe()
await waitUntilTokenTouchingCleanupFinished(coordinator)
await #expect(throws: AuthError.networkError) {
try await coordinator.accessToken()
}
#expect(await client.accessStartCount == 2)
}

@Test func timedOutAccessTokenPhaseRetriesAfterBoundedReset() async throws {
let clock = ManualTestClock()
let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ extension CmxIrohClientRuntime {
}

static func isConnectivity(_ error: any Error) -> Bool {
(error as? CmxIrohTrustBrokerClientError) == .connectivity
(error as? CmxIrohTrustBrokerClientError)?.isConnectivity == true
}

/// Failures that may fall back to the verified offline policy cache.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,6 @@ public actor CmxIrohOnlineAdmissionRegistry {
}

private static func isConnectivity(_ error: any Error) -> Bool {
(error as? CmxIrohTrustBrokerClientError) == .connectivity
(error as? CmxIrohTrustBrokerClientError)?.isConnectivity == true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,6 @@ public actor CmxIrohRegistryContextProvider: CmxIrohClientContextProvider {
}

private static func isConnectivity(_ error: any Error) -> Bool {
(error as? CmxIrohTrustBrokerClientError) == .connectivity
(error as? CmxIrohTrustBrokerClientError)?.isConnectivity == true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -812,8 +812,11 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
// and indistinguishable from an unreachable broker for every
// caller policy (retry, cached-policy fallback, verified-policy
// preservation), so classify it as connectivity, not as a
// definitive authentication failure.
throw CmxIrohTrustBrokerClientError.connectivity
// definitive authentication failure. A URL-loading failure from
// the source's own refresh call keeps its code for attribution.
throw CmxIrohTrustBrokerClientError.connectivity(
(error as? URLError).map(CmxIrohBrokerConnectivityCause.init)
)
}
guard let pair = capturedPair else {
throw CmxIrohTrustBrokerClientError.missingAuthentication
Expand All @@ -838,7 +841,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
} catch is CancellationError {
throw CancellationError()
} catch {
throw CmxIrohTrustBrokerClientError.connectivity
throw CmxIrohTrustBrokerClientError.connectivity(
(error as? URLError).map(CmxIrohBrokerConnectivityCause.init)
)
}
guard let recovered else { throw error }
return try await performAuthenticatedRequest(
Expand Down Expand Up @@ -923,7 +928,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
do {
(data, response) = try await transport.data(for: request)
} catch let error as URLError where Self.isConnectivityFailure(error.code) {
throw CmxIrohTrustBrokerClientError.connectivity
throw CmxIrohTrustBrokerClientError.connectivity(
CmxIrohBrokerConnectivityCause(error)
)
}
guard let http = response as? HTTPURLResponse else {
throw CmxIrohTrustBrokerClientError.nonHTTPResponse
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,64 @@
public import CMUXMobileCore
public import Foundation

/// Underlying URL-loading failure carried by connectivity-class broker
/// errors, so journals and caller retry policies can distinguish a dead
/// kept-alive connection (NSURLErrorNetworkConnectionLost) from DNS loss,
/// timeouts, or a token source that could not produce a coherent pair.
public struct CmxIrohBrokerConnectivityCause: Equatable, Sendable,
CustomStringConvertible
{
/// NSURLErrorDomain code, e.g. -1005.
public let urlErrorCode: Int

public init(urlErrorCode: Int) {
self.urlErrorCode = urlErrorCode
}

public init(_ error: URLError) {
self.init(urlErrorCode: error.code.rawValue)
}

/// Whether this is the connection-reuse failure class: a pooled
/// keep-alive connection the server closed while it sat idle, surfaced
/// only when the next request's first read fails. URLSession never
/// transparently retries a request whose body bytes were already written
/// (Apple QA1941), so idempotent callers retry once themselves; the
/// failed attempt already purged the dead pooled connection.
public var isConnectionReuseFailure: Bool {
urlErrorCode == URLError.Code.networkConnectionLost.rawValue
}

public var description: String { "\(symbolicName)(\(urlErrorCode))" }

private var symbolicName: String {
switch URLError.Code(rawValue: urlErrorCode) {
case .timedOut: "timedOut"
case .cannotFindHost: "cannotFindHost"
case .cannotConnectToHost: "cannotConnectToHost"
case .networkConnectionLost: "networkConnectionLost"
case .dnsLookupFailed: "dnsLookupFailed"
case .notConnectedToInternet: "notConnectedToInternet"
case .internationalRoamingOff: "internationalRoamingOff"
case .callIsActive: "callIsActive"
case .dataNotAllowed: "dataNotAllowed"
case .cannotLoadFromNetwork: "cannotLoadFromNetwork"
default: "urlError"
}
}
}

/// Failures at the authenticated HTTP trust-broker boundary.
public enum CmxIrohTrustBrokerClientError:
CmxRetryAfterProviding,
Equatable,
Sendable
{
/// The authenticated broker could not be reached through the current network.
case connectivity
/// The authenticated broker could not be reached through the current
/// network. Carries the underlying URL-loading classification when one
/// exists; a `nil` cause is a token source that could not read a
/// coherent credential pair for a non-network reason.
case connectivity(CmxIrohBrokerConnectivityCause?)
case invalidBaseURL
case missingAuthentication
case invalidAuthentication
Expand Down Expand Up @@ -87,4 +138,43 @@ public enum CmxIrohTrustBrokerClientError:
guard case let .rateLimited(_, retryAfterSeconds) = self else { return nil }
return retryAfterSeconds
}

/// Whether this is any connectivity-class failure, regardless of the
/// underlying cause detail. Callers deciding retry or cached-state
/// policy match on this instead of value equality, which would treat
/// differently-attributed connectivity failures as distinct.
public var isConnectivity: Bool {
if case .connectivity = self { return true }
return false
}
}

extension CmxIrohTrustBrokerClientError: CustomStringConvertible {
/// Journal-stable rendering: identical to the previously synthesized
/// text for every case, except that an attributed connectivity failure
/// appends its URL-loading cause, e.g.
/// `connectivity(networkConnectionLost(-1005))`.
public var description: String {
switch self {
case .connectivity(nil):
"connectivity"
case let .connectivity(cause?):
"connectivity(\(cause))"
case .invalidBaseURL:
"invalidBaseURL"
case .missingAuthentication:
"missingAuthentication"
case .invalidAuthentication:
"invalidAuthentication"
case .nonHTTPResponse:
"nonHTTPResponse"
case let .rateLimited(code, retryAfterSeconds):
"rateLimited(code: \(String(describing: code)), "
+ "retryAfterSeconds: \(retryAfterSeconds))"
case let .rejected(statusCode, code):
"rejected(statusCode: \(statusCode), code: \(String(describing: code)))"
case .invalidResponse:
"invalidResponse"
}
}
}
Loading
Loading