diff --git a/pkg/daemon/beacon_discovery.go b/pkg/daemon/beacon_discovery.go index e2781ae8..3a860a33 100644 --- a/pkg/daemon/beacon_discovery.go +++ b/pkg/daemon/beacon_discovery.go @@ -49,6 +49,7 @@ const ( beaconRefreshInterval = routing.BeaconRefreshInterval beaconRefreshJitter = routing.BeaconRefreshJitter beaconCacheFilename = routing.BeaconCacheFilename + beaconCacheMaxAge = routing.BeaconCacheMaxAge ) type beaconLister = routing.BeaconLister @@ -166,6 +167,23 @@ func (d *Daemon) beaconRefreshTick(firstTick bool) { if firstTick { cached, cacheErr := loadBeaconCache(d.config.IdentityPath) if cacheErr == nil && len(cached) > 0 { + // Reject the cache if it is older than BeaconCacheMaxAge. + // A stale cache keeps the daemon trying offline beacons + // indefinitely; fall through to the bootstrap list instead. + savedAt, savedAtErr := routing.BeaconCacheSavedAt(d.config.IdentityPath) + if savedAtErr == nil { + age := time.Since(savedAt) + if age > beaconCacheMaxAge { + slog.Warn("beacon discovery: rejecting stale on-disk cache", + "err", err, + "cache_age", age.Truncate(time.Second), + "max_age", beaconCacheMaxAge, + ) + slog.Debug("beacon discovery skipped (registry error, stale cache)", + "err", err) + return + } + } slog.Info("beacon discovery: using on-disk cache (registry unreachable)", "err", err, "cached_count", len(cached)) discovered = cached diff --git a/pkg/daemon/routing/discovery.go b/pkg/daemon/routing/discovery.go index d6e379fd..bab0c6c8 100644 --- a/pkg/daemon/routing/discovery.go +++ b/pkg/daemon/routing/discovery.go @@ -23,6 +23,11 @@ const BeaconRefreshInterval = 60 * time.Second // The first refresh fires at t = rand[0..BeaconRefreshJitter). const BeaconRefreshJitter = 10 * time.Second +// BeaconCacheMaxAge is the maximum age of an on-disk beacon cache +// before it is considered stale and rejected in favor of the +// operator-configured bootstrap list. +const BeaconCacheMaxAge = 1 * time.Hour + // BeaconCacheFilename is the on-disk fallback used when the registry // is unreachable at cold-start. Lives next to the identity file. const BeaconCacheFilename = "beacons.json" @@ -142,6 +147,28 @@ func LoadBeaconCache(identityPath string) ([]string, error) { return FilterUnreachable(entry.Addrs), nil } +// BeaconCacheSavedAt reads the SavedAt timestamp from the on-disk cache +// without deserialising the full addr list. Returns (time.Time{}, nil) +// when the file does not exist. +func BeaconCacheSavedAt(identityPath string) (time.Time, error) { + path := BeaconCachePath(identityPath) + if path == "" { + return time.Time{}, nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return time.Time{}, nil + } + return time.Time{}, fmt.Errorf("read beacon cache for SavedAt: %w", err) + } + var entry BeaconCacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return time.Time{}, fmt.Errorf("parse beacon cache for SavedAt: %w", err) + } + return entry.SavedAt, nil +} + // BeaconSelectionState tracks the daemon's beacon picks across refresh // ticks. Pure data — the refresh logic mutates it under its mutex, // and the daemon hot path reads via GetCurrentPick().