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
4 changes: 4 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B5024B0A05F3D9754101F1 /* SyncService.swift */; };
B70000000000000000000002 /* SyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000001 /* SyncRecoveryPolicy.swift */; };
B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */; };
B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90000000000000000000001 /* SyncTransportSelectionTests.swift */; };
B80000000000000000000002 /* SyncConnectionRace.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80000000000000000000001 /* SyncConnectionRace.swift */; };
B80000000000000000000004 /* SyncTerminalInputQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80000000000000000000003 /* SyncTerminalInputQueue.swift */; };
A11700000000000000000002 /* MobileUsageQuotaStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11700000000000000000001 /* MobileUsageQuotaStore.swift */; };
Expand Down Expand Up @@ -445,6 +446,7 @@
66B5024B0A05F3D9754101F1 /* SyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncService.swift; path = ADE/Services/SyncService.swift; sourceTree = "<group>"; };
B70000000000000000000001 /* SyncRecoveryPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicy.swift; path = ADE/Services/SyncRecoveryPolicy.swift; sourceTree = "<group>"; };
B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicyTests.swift; path = ADETests/SyncRecoveryPolicyTests.swift; sourceTree = "<group>"; };
B90000000000000000000001 /* SyncTransportSelectionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncTransportSelectionTests.swift; path = ADETests/SyncTransportSelectionTests.swift; sourceTree = "<group>"; };
B80000000000000000000001 /* SyncConnectionRace.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncConnectionRace.swift; path = ADE/Services/SyncConnectionRace.swift; sourceTree = "<group>"; };
B80000000000000000000003 /* SyncTerminalInputQueue.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncTerminalInputQueue.swift; path = ADE/Services/SyncTerminalInputQueue.swift; sourceTree = "<group>"; };
A11700000000000000000001 /* MobileUsageQuotaStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MobileUsageQuotaStore.swift; path = ADE/Services/MobileUsageQuotaStore.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1050,6 +1052,7 @@
14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */,
AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */,
B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */,
B90000000000000000000001 /* SyncTransportSelectionTests.swift */,
AF00000000000000000000A4 /* PairingAndDpopTests.swift */,
AC1000000000000000000008 /* ClipPairingHandoffTests.swift */,
D30000000000000000000005 /* AttentionDrawerModelTests.swift */,
Expand Down Expand Up @@ -1549,6 +1552,7 @@
7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */,
AD0000000000000000000B06 /* AccountEmailAuthFlowTests.swift in Sources */,
B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */,
B90000000000000000000002 /* SyncTransportSelectionTests.swift in Sources */,
AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */,
AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */,
D30000000000000000000015 /* AttentionDrawerModelTests.swift in Sources */,
Expand Down
39 changes: 39 additions & 0 deletions apps/ios/ADE/Models/RemoteModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,40 @@ struct ConnectionDraft: Codable, Equatable {
struct HostConnectionEndpointState: Codable, Equatable {
var endpoint: String
var lastSucceededAt: TimeInterval?
/// Failure history for this route. All optional so profiles written before
/// failure memory existed keep decoding. A route that just failed twice in a
/// row is still raced — it is scheduled last instead of consuming slot 0 and
/// its 5s open timeout.
var lastFailedAt: TimeInterval?
var consecutiveFailures: Int?
/// True once this relay endpoint has completed a `?ready=2` handshake. Until
/// then a missing `accepted` may mean a pre-v2 relay and earns one legacy
/// redial; afterwards it never does, which removes the routine double-dial.
var negotiatedReadyV2: Bool?

init(
endpoint: String,
lastSucceededAt: TimeInterval? = nil,
lastFailedAt: TimeInterval? = nil,
consecutiveFailures: Int? = nil,
negotiatedReadyV2: Bool? = nil
) {
self.endpoint = endpoint
self.lastSucceededAt = lastSucceededAt
self.lastFailedAt = lastFailedAt
self.consecutiveFailures = consecutiveFailures
self.negotiatedReadyV2 = negotiatedReadyV2
}
}

/// Which endpoint last authenticated on a given network. Keyed by a coarse
/// network fingerprint (`wifi:<own /24>`, `cell`, `wired`) so returning to a
/// known network dials the route that actually worked there first, instead of
/// re-deriving it from a global last-good that belongs to a different network.
struct HostConnectionNetworkRouteMemory: Codable, Equatable {
var fingerprint: String
var endpoint: String
var updatedAt: TimeInterval
}

struct HostConnectionProfile: Codable, Equatable {
Expand Down Expand Up @@ -46,6 +80,9 @@ struct HostConnectionProfile: Codable, Equatable {
/// Health history for direct and relay routes. Optional so profiles written
/// before route stickiness was introduced continue to decode cleanly.
var endpointStates: [HostConnectionEndpointState]?
/// Per-network winning route, most-recently-used first and capped. Optional
/// for the same decode-compatibility reason as `endpointStates`.
var networkRouteMemory: [HostConnectionNetworkRouteMemory]?
/// Clerk user id that authorized this pairing. `nil` means the machine was
/// paired directly (QR, link, nearby, address, or SSH) and must survive an
/// ADE account sign-out. Account-owned profiles and their keychain secrets
Expand Down Expand Up @@ -75,6 +112,7 @@ struct HostConnectionProfile: Codable, Equatable {
tailscaleAddress: String?,
savedRelayCandidates: [String]? = nil,
endpointStates: [HostConnectionEndpointState]? = nil,
networkRouteMemory: [HostConnectionNetworkRouteMemory]? = nil,
accountOwnerId: String? = nil,
relayAccountOwnerId: String? = nil,
updatedAt: String = ISO8601DateFormatter().string(from: Date())
Expand All @@ -94,6 +132,7 @@ struct HostConnectionProfile: Codable, Equatable {
self.tailscaleAddress = tailscaleAddress
self.savedRelayCandidates = savedRelayCandidates
self.endpointStates = endpointStates
self.networkRouteMemory = networkRouteMemory
self.accountOwnerId = accountOwnerId
self.relayAccountOwnerId = relayAccountOwnerId
self.updatedAt = updatedAt
Expand Down
208 changes: 204 additions & 4 deletions apps/ios/ADE/Services/SyncConnectionRace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,66 @@ enum SyncConnectionRaceTiming {
static let candidateStaggerNanoseconds: UInt64 = 250_000_000
static let overallBudgetNanoseconds: UInt64 = 10_000_000_000
static let maximumCandidateCount = 3
static let relayReadyNegotiationNanoseconds: UInt64 = 350_000_000
/// Deadline for the relay's `{t:"accepted",v:2}` frame. The Worker sends it
/// the moment `/connect` is served, before any host signaling, so this window
/// only has to cover the WebSocket upgrade itself.
static let relayAcceptedNegotiationNanoseconds: UInt64 = 350_000_000
/// Budget for `ready` once `accepted` has arrived. `accepted` already proves
/// the relay is live and the host is registered, so the remaining wait covers
/// a cold Durable Object waking, dialing the host pipe and the local port —
/// far more than the 350ms `accepted` window, which is why a cold endpoint
/// used to be redialed on principle.
///
/// It must stay strictly under `overallBudgetNanoseconds`: a relay that
/// accepts and then never bridges has to fail *inside* the race, so the
/// candidate is recorded as a failed endpoint and its concurrency slot and
/// dial key are freed. If the race budget expired first, that endpoint would
/// never accumulate a failure streak and would keep leading the plan.
static let relayReadyAfterAcceptedNanoseconds: UInt64 = 7_000_000_000
/// How long a relay candidate holds back once a direct candidate is already
/// dialing. Direct routes win outright on a LAN, so this is the whole cost of
/// keeping relay in the same race; on cellular no direct candidate is
/// plausible and relay is dialed essentially immediately.
static let relayJoinDelayNanoseconds: UInt64 = 300_000_000
}

/// A route counts as failing when it has failed at least twice in a row and the
/// most recent failure is recent enough to still describe the network we are on.
enum SyncEndpointFailureMemory {
static let consecutiveFailureThreshold = 2
static let recentFailureWindowSeconds: TimeInterval = 120
}

func syncEndpointIsRecentlyFailing(
_ state: HostConnectionEndpointState?,
now: TimeInterval
) -> Bool {
guard let state,
let lastFailedAt = state.lastFailedAt,
(state.consecutiveFailures ?? 0) >= SyncEndpointFailureMemory.consecutiveFailureThreshold
else { return false }
return now - lastFailedAt <= SyncEndpointFailureMemory.recentFailureWindowSeconds
}

/// Most-recently-used cap for the per-network route memory.
let syncNetworkRouteMemoryCapacity = 8

/// Lower is better: a LAN route beats a tailnet route beats the relay. The
/// ordering is load-bearing for ranking and for deciding what counts as an
/// upgrade, so it is expressed once here instead of via `.rawValue` at each site.
extension SyncConnectionRouteKind: Comparable {
static func < (lhs: SyncConnectionRouteKind, rhs: SyncConnectionRouteKind) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

/// Thrown when the single-flight guard refuses a second concurrent `/connect`
/// dial for one machine. It describes this attempt, never the endpoint, so it
/// must not be recorded as an endpoint failure.
struct SyncRelayDialInFlight: Error, LocalizedError {
var errorDescription: String? {
"A connection to this machine through the ADE relay is already opening."
}
}

func syncObservedConnectionRouteKind(
Expand Down Expand Up @@ -102,11 +161,139 @@ struct SyncRelayReadyNegotiation: Equatable {
}
}

/// Budget for the next frame, measured from the moment the current phase
/// began. `accepted` moves the socket into the long phase: it has proven the
/// relay accepted us and the host is registered, so waiting is no longer a
/// gamble on a dead endpoint.
var phaseBudgetNanoseconds: UInt64 {
acceptedV2
? SyncConnectionRaceTiming.relayReadyAfterAcceptedNanoseconds
: SyncConnectionRaceTiming.relayAcceptedNegotiationNanoseconds
}

func negotiationWindowExpired() -> SyncRelayReadyNegotiationDecision {
acceptedV2 ? .interceptedWaiting : .retryLegacySocket
}
}

/// A missing `accepted` is only evidence of a pre-v2 relay the first time we
/// meet an endpoint. Once an endpoint has completed a `?ready=2` handshake, a
/// silent window means the relay or the host is unwell, and redialing without
/// `ready=2` just burns a second tunnel slot against the Durable Object's cap.
func syncRelayLegacyRedialAllowed(
acceptedV2Observed: Bool,
endpointNegotiatedReadyV2Before: Bool
) -> Bool {
!acceptedV2Observed && !endpointNegotiatedReadyV2Before
}

/// `wss://host/connect/<machineKey>` → `host/<machineKey>`. Two in-flight dials
/// for the same value would open two tunnels on the same Durable Object, whose
/// 16-tunnel cap is only swept every 5-10 minutes; the orphans surface later as
/// an unexplained 4503.
func syncRelayMachineKey(from rawValue: String) -> String? {
guard let components = URLComponents(string: rawValue.trimmingCharacters(in: .whitespacesAndNewlines)),
let host = components.host?.lowercased(),
!host.isEmpty else { return nil }
let key = components.path
.split(separator: "/")
.last
.map(String.init)?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let key, !key.isEmpty else { return nil }
return "\(host)/\(key)"
}

/// Single-flight guard over relay `/connect` dials, keyed by machine.
///
/// Holders are identified by a token so that abandoning a whole connect attempt
/// (`releaseAll`) is safe: a superseded dial unwinding afterwards releases with
/// a stale token and is ignored, instead of freeing a key the new attempt owns.
struct SyncRelayDialRegistry: Equatable {
private(set) var inFlight: [String: UUID] = [:]

var inFlightKeys: Set<String> { Set(inFlight.keys) }

/// The token to release with, or nil when another dial already holds the key.
mutating func acquire(_ key: String, token: UUID = UUID()) -> UUID? {
guard inFlight[key] == nil else { return nil }
inFlight[key] = token
return token
}

mutating func release(_ key: String, token: UUID) {
guard inFlight[key] == token else { return }
inFlight.removeValue(forKey: key)
}

/// Abandons every in-flight dial. Called when a new connect attempt
/// supersedes the old one, whose sockets are being cancelled anyway.
mutating func releaseAll() {
inFlight.removeAll()
}
}

/// Coarse identity for the network the phone is on right now, or nil when there
/// is no usable path to remember. Wi-Fi carries the phone's own IPv4 /24 so
/// home, office and hotspot are distinguishable; an SSID would need an
/// entitlement the app does not hold, and the /24 separates the same networks
/// well enough for "which route worked here".
func syncNetworkFingerprint(
usesWiFi: Bool,
usesCellular: Bool,
usesWiredEthernet: Bool,
interfaceAddresses: [SyncNetworkInterfaceAddress] = []
) -> String? {
if usesWiredEthernet { return "wired" }
if usesWiFi {
guard let prefix = syncLocalIPv4SubnetPrefix(interfaceAddresses) else { return "wifi" }
return "wifi:\(prefix)"
}
if usesCellular { return "cell" }
return nil
}

/// The `a.b.c` prefix of this device's own LAN IPv4, taken from a wired/Wi-Fi
/// interface. Tunnels (Tailscale) and loopback are skipped — they do not
/// identify the underlying network.
func syncLocalIPv4SubnetPrefix(_ interfaceAddresses: [SyncNetworkInterfaceAddress]) -> String? {
for entry in interfaceAddresses {
guard entry.interfaceName.hasPrefix("en") else { continue }
let octets = entry.address.split(separator: ".")
guard octets.count == 4, octets.allSatisfy({ UInt8($0) != nil }) else { continue }
if octets[0] == "127" { continue }
if entry.address.hasPrefix("169.254.") { continue }
return octets.prefix(3).joined(separator: ".")
}
return nil
}

func syncRememberedEndpoint(
in memory: [HostConnectionNetworkRouteMemory]?,
fingerprint: String?
) -> String? {
guard let fingerprint else { return nil }
return memory?.first(where: { $0.fingerprint == fingerprint })?.endpoint
}

func syncNetworkRouteMemoryRecording(
_ memory: [HostConnectionNetworkRouteMemory]?,
fingerprint: String?,
endpoint: String,
at updatedAt: TimeInterval,
capacity: Int = syncNetworkRouteMemoryCapacity
) -> [HostConnectionNetworkRouteMemory] {
let trimmedEndpoint = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
guard let fingerprint, !trimmedEndpoint.isEmpty else { return memory ?? [] }
let entry = HostConnectionNetworkRouteMemory(
fingerprint: fingerprint,
endpoint: trimmedEndpoint,
updatedAt: updatedAt
)
let retained = (memory ?? []).filter { $0.fingerprint != fingerprint }
return Array(([entry] + retained).prefix(max(1, capacity)))
}

struct SyncConnectionRaceScheduledCandidate: Equatable {
var id: Int
var endpoint: SyncConnectionEndpointAttempt
Expand Down Expand Up @@ -151,10 +338,18 @@ func syncConnectionRacePlan(
}
}

/// The whole connect plan: direct and relay candidates in ONE race, happy-eyeballs
/// style. Relay used to be raced only after the direct race exhausted its 10s
/// budget, which is a 10-20s stall on cellular where no direct candidate can
/// ever succeed. Here relay is scheduled behind the leading direct candidate by
/// `relayJoinDelayNanoseconds` — unless it leads the ranking itself (proven
/// last-good or the remembered winner for this network), in which case it is
/// dialed at t=0 and cellular reconnects are as fast as LAN ones.
func syncConnectionRaceCandidatePlan(
rankedAttempts: [SyncConnectionEndpointAttempt],
maximumConcurrentCandidateCount: Int = SyncConnectionRaceTiming.maximumCandidateCount,
staggerNanoseconds: UInt64 = SyncConnectionRaceTiming.candidateStaggerNanoseconds
staggerNanoseconds: UInt64 = SyncConnectionRaceTiming.candidateStaggerNanoseconds,
relayJoinDelayNanoseconds: UInt64 = SyncConnectionRaceTiming.relayJoinDelayNanoseconds
) -> [SyncConnectionRaceScheduledCandidate] {
let firstWave = syncConnectionRacePlan(
rankedAttempts: rankedAttempts,
Expand All @@ -164,10 +359,15 @@ func syncConnectionRaceCandidatePlan(
let firstWaveEndpoints = Set(firstWave.map(\.endpoint))
let remaining = rankedAttempts.filter { !firstWaveEndpoints.contains($0) }
return (firstWave.map(\.endpoint) + remaining).enumerated().map { offset, endpoint in
SyncConnectionRaceScheduledCandidate(
var delayNanoseconds = offset < firstWave.count ? UInt64(offset) * staggerNanoseconds : 0
// Stragglers already wait for a free slot; only the opening wave staggers.
if offset > 0, offset < firstWave.count, syncConnectionRouteKind(endpoint.address) == .relay {
delayNanoseconds = max(delayNanoseconds, relayJoinDelayNanoseconds)
}
return SyncConnectionRaceScheduledCandidate(
id: offset,
endpoint: endpoint,
delayNanoseconds: offset < firstWave.count ? UInt64(offset) * staggerNanoseconds : 0
delayNanoseconds: delayNanoseconds
)
}
}
Expand Down
Loading