Merge nucleic/rustic-maple-marten into dev

This commit is contained in:
2026-07-12 03:00:32 -07:00
parent f87ba9a7a3
commit 3da6338b0a
2 changed files with 34 additions and 4 deletions
@@ -264,7 +264,8 @@ final class HostConnection {
// a gossiped address carries only the IP. // a gossiped address carries only the IP.
tailnetPort: hasTailnet ? 43_753 : nil, tailnetPort: hasTailnet ? 43_753 : nil,
relayRoomID: member.addresses?.relayRoomID, 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 /// 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 guard fingerprint != self.hostID else { continue } // that's the Mac we're on
let incoming = Self.pairedHost(from: member) let incoming = Self.pairedHost(from: member)
let existing = IdentityStore.pairedHost(id: fingerprint) 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 // 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 // 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 // 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 // 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), // 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. // 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 changed = true
} }
IdentityStore.mergePairedHost(incoming)
} }
for tombstone in push.tombstones { for tombstone in push.tombstones {
// fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key. // fingerprint = first 16 hex of the hostID (sha256 prefix), the registry key.
@@ -722,6 +732,13 @@ final class HostConnection {
if case .failed = connectivity {} else { if case .failed = connectivity {} else {
connectivity = .failed("Couldn't connect to your Mac — check that it's reachable, then scan again.") 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 connectPlan = nil
callbacks.didUpdate() callbacks.didUpdate()
@@ -28,6 +28,11 @@ struct PairedHost: Codable, Equatable {
var relayRoomID: String? var relayRoomID: String?
var relayMembershipToken: String? var relayMembershipToken: String?
var relayURL: 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 } var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
} }
@@ -119,9 +124,17 @@ enum IdentityStore {
static func mergePairedHost(_ host: PairedHost) { static func mergePairedHost(_ host: PairedHost) {
var hosts = pairedHosts() var hosts = pairedHosts()
if let index = hosts.firstIndex(where: { $0.fingerprint == host.fingerprint }) { if let index = hosts.firstIndex(where: { $0.fingerprint == host.fingerprint }) {
var merged = host
let existing = hosts[index] 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.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.relayMembershipToken = host.relayMembershipToken ?? existing.relayMembershipToken
merged.relayRoomID = host.relayRoomID ?? existing.relayRoomID merged.relayRoomID = host.relayRoomID ?? existing.relayRoomID
merged.relayURL = host.relayURL ?? existing.relayURL merged.relayURL = host.relayURL ?? existing.relayURL