feat: enhance relay descriptor handling with upsert result statuses

Kim committed Apr 15, 2026 at 16:11 UTC 3fe3ce6c273b2d34399d226972b6acd756273ca5
2 files changed +42 -21
frontend/src/components/ServerListView.tsx
+5 -7
@@ -869,13 +869,11 @@ export function ServerListView({
869 >
870 {relay.relayURL}
871 </a>
872 - {relayReleaseVersions[relay.relayURL] ? (
873 - <div className="flex shrink-0 items-center gap-2">
874 - <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
875 - {relayReleaseVersions[relay.relayURL]}
876 - </span>
877 - </div>
878 - ) : null}
872 + <div className="flex shrink-0 items-center gap-2">
873 + <span className="rounded-full bg-background px-2.5 py-1 font-mono text-[11px] font-medium text-text-muted ring-1 ring-border">
874 + {relayReleaseVersions[relay.relayURL] || "offline"}
875 + </span>
876 + </div>
877 </div>
878 ))}
879 </div>
portal/discovery/relayset.go
+37 -14
@@ -59,6 +59,14 @@ type keyIndexEntry struct {
59 TombstoneUntil time.Time
60 }
61
62 +type upsertResult int
63 +
64 +const (
65 + upsertRejected upsertResult = iota
66 + upsertAccepted
67 + upsertIgnored
68 +)
69 +
70 func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
71 set := &RelaySet{
72 relays: make(map[string]RelayState),
@@ -72,7 +80,8 @@ func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
80 // upsertDescriptorLocked applies a fully-merged RelayState to s.relays and
81 // updates the keyIndex. The caller MUST already hold s.mu as a write lock.
82 //
75 -// The returned bool indicates whether the descriptor was accepted. The
83 +// The returned status indicates whether the descriptor was accepted, ignored
84 +// as an already-superseded same-URL/same-identity announce, or rejected. The
85 // upsert is rejected when:
86 //
87 // 1. The signing identity has previously published a strictly newer
@@ -90,10 +99,10 @@ func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
99 // Equal IssuedAt values (idempotent re-broadcast) are accepted because the
100 // only mutation is the merged local telemetry on the existing URL slot,
101 // which never contradicts the cryptographic identity of the descriptor.
93 -func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allowCrossIdentityTakeover bool) bool {
102 +func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allowCrossIdentityTakeover bool) upsertResult {
103 relayURL := record.Descriptor.APIHTTPSAddr
104 if relayURL == "" {
96 - return false
105 + return upsertRejected
106 }
107 address := strings.ToLower(strings.TrimSpace(record.Descriptor.Address))
108 if address != "" {
@@ -104,7 +113,14 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
113 if !prev.TombstoneUntil.IsZero() && now.After(prev.TombstoneUntil) {
114 delete(s.keyIndex, address)
115 } else if record.Descriptor.IssuedAt.Before(prev.IssuedAt) {
107 - return false
116 + if existing, ok := s.relays[relayURL]; ok {
117 + existingAddress := strings.ToLower(strings.TrimSpace(existing.Descriptor.Address))
118 + if existingAddress == address && existing.Descriptor.ExpiresAt.After(now) &&
119 + !existing.Descriptor.IssuedAt.Before(record.Descriptor.IssuedAt) {
120 + return upsertIgnored
121 + }
122 + }
123 + return upsertRejected
124 }
125 }
126 }
@@ -113,7 +129,7 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
129 existingAddress := strings.ToLower(strings.TrimSpace(existing.Descriptor.Address))
130 if existingAddress != "" && address != "" && existingAddress != address {
131 if !existing.Descriptor.ExpiresAt.IsZero() && existing.Descriptor.ExpiresAt.After(now) {
116 - return false
132 + return upsertRejected
133 }
134 }
135 }
@@ -135,7 +151,7 @@ func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allo
151 TombstoneUntil: tombstoneUntil,
152 }
153 }
138 - return true
154 + return upsertAccepted
155 }
156
157 func (s *RelaySet) SetRelayPolicy(policy RelayPolicy) {
@@ -403,11 +419,13 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
419 record.nextDirectRefreshAt = time.Time{}
420 }
421
406 - if !s.upsertDescriptorLocked(record, now, isAuthoritativeTarget) {
422 + if upsert := s.upsertDescriptorLocked(record, now, isAuthoritativeTarget); upsert != upsertAccepted {
423 // The monotonic-IssuedAt check rejected this descriptor as a
408 - // rollback. The cryptographic identity in s.relays is unchanged,
409 - // but if we successfully reached the authoritative target we
410 - // should still credit it as alive on its existing URL slot.
424 + // rollback, or ignored it because a newer same-identity descriptor
425 + // for this URL is already present. The cryptographic identity in
426 + // s.relays is unchanged, but if we successfully reached the
427 + // authoritative target we should still credit it as alive on its
428 + // existing URL slot.
429 if isAuthoritativeTarget && hasExistingAtURL {
430 if existingAtURL.consecutiveFailures != 0 || !existingAtURL.nextDirectRefreshAt.IsZero() {
431 existingAtURL.consecutiveFailures = 0
@@ -467,7 +485,8 @@ func (s *RelaySet) RecordDiscoveryRTT(relayURL string, rtt time.Duration, measur
485 // 5. After a successful upsert, the LRU cap is enforced; bootstrap and
486 // listener-confirmed entries are pinned.
487 //
470 -// Returns nil iff the descriptor was stored or idempotently refreshed.
488 +// Returns nil iff the descriptor was stored, idempotently refreshed, or is an
489 +// older same-URL/same-identity announce already superseded by local state.
490 func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) error {
491 if now.IsZero() {
492 now = time.Now().UTC()
@@ -506,11 +525,15 @@ func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) er
525 }
526 }
527
509 - if !s.upsertDescriptorLocked(record, now, false) {
528 + switch s.upsertDescriptorLocked(record, now, false) {
529 + case upsertAccepted:
530 + s.enforceCapLocked()
531 + return nil
532 + case upsertIgnored:
533 + return nil
534 + case upsertRejected:
535 return errors.New("announced descriptor rejected by rollback or takeover guard")
536 }
512 -
513 - s.enforceCapLocked()
537 return nil
538 }
539