diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index 5e1a7d3..c90fa71 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -264,7 +264,8 @@ final class HostConnection { // a gossiped address carries only the IP. tailnetPort: hasTailnet ? 43_753 : nil, relayRoomID: member.addresses?.relayRoomID, - relayMembershipToken: nil, relayURL: nil) + relayMembershipToken: nil, relayURL: nil, + addressesUpdatedAt: member.addresses?.updatedAt) } /// Whether the dialable endpoints of a gossiped record differ from the one on file — the @@ -588,16 +589,25 @@ final class HostConnection { guard fingerprint != self.hostID else { continue } // that's the Mac we're on let incoming = Self.pairedHost(from: member) let existing = IdentityStore.pairedHost(id: fingerprint) + IdentityStore.mergePairedHost(incoming) // Re-dial not only when a Mac is brand-new, but also when a known Mac's dialable // address changed — a Mac's LAN port is OS-assigned (`.any`), so it lands on a // fresh one every relaunch and re-gossips it. `mergePairedHost` updates the // registry in place, but a connection already retrying the dead old address won't // pick that up on its own (its retry loop reuses the address it was last handed), // so without forcing a fresh reconnect here the phone never reconnects to it. - if existing == nil || Self.dialableAddressChanged(from: existing!, to: incoming) { + // + // Compare against the POST-merge record, not the raw gossip: the merge deliberately + // preserves fields the gossip omits (a relay room learned from the host's own + // `relayMembership` push, stale-vs-fresh address arbitration), so a member gossiped + // by a Mac that lacks those fields is NOT a change. Comparing pre-merge made every + // such push read as "changed" forever — a mesh-wide reconnect storm that tore down + // in-flight handshakes on each gossip wave and pinned connections on "Connecting…". + let merged = IdentityStore.pairedHost(id: fingerprint) + if existing == nil + || (merged != nil && Self.dialableAddressChanged(from: existing!, to: merged!)) { changed = true } - IdentityStore.mergePairedHost(incoming) } for tombstone in push.tombstones { // fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key. @@ -722,6 +732,13 @@ final class HostConnection { if case .failed = connectivity {} else { connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.") } + } else if case .failed = connectivity { + // keep the surfaced error + } else { + // Reconnect-mode chain exhausted through channel closes (the LAN/relay connect + // watchdogs surface that way) — mirror `asyncAttemptFailed` so the row reads + // "host offline" through the retry backoff instead of a stale "Connecting…". + connectivity = .hostOffline } connectPlan = nil callbacks.didUpdate() diff --git a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift index fa26ed4..d80eafe 100644 --- a/NucleicRemote/NucleicRemote/Models/IdentityStore.swift +++ b/NucleicRemote/NucleicRemote/Models/IdentityStore.swift @@ -28,6 +28,11 @@ struct PairedHost: Codable, Equatable { var relayRoomID: String? var relayMembershipToken: String? var relayURL: String? + /// When the gossiped addresses this record's dial hints came from were stamped at their + /// origin (`PeerAddresses.updatedAt`) — the freshness clock `mergePairedHost` arbitrates + /// with, so a Mac gossiping a stale snapshot can't clobber a fresher one. Nil for records + /// predating the field and for hints seeded straight from a pairing QR. + var addressesUpdatedAt: Date? = nil var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan } } @@ -119,9 +124,17 @@ enum IdentityStore { static func mergePairedHost(_ host: PairedHost) { var hosts = pairedHosts() if let index = hosts.firstIndex(where: { $0.fingerprint == host.fingerprint }) { - var merged = host let existing = hosts[index] + // Freshness gate on the dial hints: two Macs can gossip disagreeing snapshots of a + // third's addresses (one still holds its pre-relaunch LAN port). Adopting the incoming + // hints blindly made the record — and every dial loop keyed off it — flip-flop between + // the stale and fresh snapshots on alternating pushes. The origin's `updatedAt` rides + // in as `addressesUpdatedAt`; an undated record loses to any dated one. + let incomingAt = host.addressesUpdatedAt ?? .distantPast + let existingAt = existing.addressesUpdatedAt ?? .distantPast + var merged = incomingAt >= existingAt ? host : existing merged.deviceID = existing.deviceID // keep our established id for this host + merged.hostName = host.hostName // labels carry no clock; newest gossip wins merged.relayMembershipToken = host.relayMembershipToken ?? existing.relayMembershipToken merged.relayRoomID = host.relayRoomID ?? existing.relayRoomID merged.relayURL = host.relayURL ?? existing.relayURL