Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
31 changes: 0 additions & 31 deletions backend/exhaustion_test.go

This file was deleted.

113 changes: 101 additions & 12 deletions backend/radiance.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,17 +262,15 @@ func (r *LocalBackend) Start() {
span.End() // point-in-time marker — config was received at this timestamp
}
}
if err := r.setServers(list, true); err != nil {
slog.Error("setting servers in manager", "error", err)
if err := r.updateServers(list); err != nil {
slog.Error("updating servers in manager", "error", err)
}
Comment thread
Copilot marked this conversation as resolved.
if err := r.RunOfflineURLTests(); err != nil && !errors.Is(err, vpn.ErrTunnelAlreadyConnected) {
// ErrTunnelAlreadyConnected is the expected, non-error case while
// the VPN is up: setServers above already pushed the new outbounds
// (and any bandit URL overrides) into the running tunnel, and
// addOutbounds triggers an immediate probe cycle for them via
// MutableAutoSelect.CheckOutbounds. The "offline" pre-warm path
// here is for the not-yet-connected case only — running both
// would duplicate work and conflict with the live auto-select group.
// ErrTunnelAlreadyConnected is expected while the VPN is up:
// updateServers already pushed the new outbounds into the live
// tunnel via UpdateOutbounds, so the offline pre-warm — which
// targets the not-yet-connected case — would duplicate work and
// conflict with the live auto-select group.
slog.Error("Failed to run offline URL tests after config update", "error", err)
}
})
Expand Down Expand Up @@ -613,9 +611,40 @@ func (r *LocalBackend) RevokePrivateServerInvite(ip string, port int, accessToke
return r.srvManager.RevokePrivateServerInvite(ip, port, accessToken, inviteName)
}

func (r *LocalBackend) setServers(list servers.ServerList, isLantern bool) error {
if err := r.srvManager.SetServers(list, isLantern); err != nil {
return fmt.Errorf("failed to set servers in ServerManager: %w", err)
// maxRetainedLanternServers caps the number of working Lantern servers retained
// across config updates.
const maxRetainedLanternServers = 60

func (r *LocalBackend) updateServers(list servers.ServerList) error {
existing := r.srvManager.AllServers()
// prune any servers from the incoming list that already exists to avoid deleting
// selection history results and closing existing connections
existingTags := serverTagSet(existing)
list.Servers = slices.DeleteFunc(list.Servers, func(srv *servers.Server) bool {
_, exists := existingTags[srv.Tag]
return exists
})

tagsToEvict := lanternServersToEvict(existing, len(list.Servers), maxRetainedLanternServers)

if len(tagsToEvict) > 0 {
slog.Debug(
"Evicting retained Lantern servers to make room for new config batch",
"count", len(tagsToEvict),
"tags", tagsToEvict,
)
if _, err := r.srvManager.RemoveServers(tagsToEvict); err != nil {
return fmt.Errorf("remove retained Lantern servers: %w", err)
}
}

slog.Debug(
"Adding new Lantern servers from config update",
"count", len(list.Servers),
"tags", slices.Collect(maps.Keys(serverTagSet(list.Servers))),
)
if err := r.srvManager.AddServers(list, false); err != nil {
return fmt.Errorf("add Lantern servers: %w", err)
}
// updateOutbounds evicts any outbound absent from the list; include all
// servers so user-added outbounds aren't removed on a Lantern config update.
Expand All @@ -629,6 +658,66 @@ func (r *LocalBackend) setServers(list servers.ServerList, isLantern bool) error
return nil
}

func serverTagSet(list []*servers.Server) map[string]struct{} {
tags := make(map[string]struct{}, len(list))
for _, srv := range list {
tags[srv.Tag] = struct{}{}
}
return tags
}

// lanternServersToEvict returns the Lantern server tags to remove before the
// next config batch is added. Hard-demoted servers are removed first. Remaining
// candidates are evicted oldest-first by SelectionHistory.UpdatedAt; missing
// history sorts oldest.
func lanternServersToEvict(
existing []*servers.Server,
incomingCount, limit int,
) []string {
tagsToEvict := make([]string, 0)
retentionCandidates := make([]*servers.Server, 0, len(existing))

for _, srv := range existing {
if !srv.IsLantern {
continue
}
if isHardDemoted(srv) {
tagsToEvict = append(tagsToEvict, srv.Tag)
continue
}
retentionCandidates = append(retentionCandidates, srv)
}

retentionBudget := max(limit-incomingCount, 0)
if len(retentionCandidates) <= retentionBudget {
return tagsToEvict
}

slices.SortFunc(retentionCandidates, compareSelectionAge)

overflow := len(retentionCandidates) - retentionBudget
for _, srv := range retentionCandidates[:overflow] {
tagsToEvict = append(tagsToEvict, srv.Tag)
}

return tagsToEvict
}

func isHardDemoted(srv *servers.Server) bool {
return srv.SelectionHistory != nil && srv.SelectionHistory.HardDemoted
}

func compareSelectionAge(a, b *servers.Server) int {
return selectionUpdatedAt(a).Compare(selectionUpdatedAt(b))
}

func selectionUpdatedAt(srv *servers.Server) time.Time {
if srv.SelectionHistory == nil {
return time.Time{}
}
return srv.SelectionHistory.UpdatedAt
}

// clearSelectedIfMissing reverts the persisted selection to auto-select when
// the selected server is no longer present in the manager.
func (r *LocalBackend) clearSelectedIfMissing() {
Expand Down
120 changes: 120 additions & 0 deletions backend/radiance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package backend

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/getlantern/radiance/servers"
)

func TestExhaustionGate_AllowRateLimitsBelowGap(t *testing.T) {
prev := defaultExhaustionRefetchGap
defaultExhaustionRefetchGap = 50 * time.Millisecond
t.Cleanup(func() { defaultExhaustionRefetchGap = prev })

var g exhaustionGate
require.True(t, g.allow(), "first allow must pass on a zero gate")
assert.False(t, g.allow(), "second allow inside the gap must be rate-limited")
assert.False(t, g.allow(), "third allow inside the gap must still be rate-limited")

time.Sleep(defaultExhaustionRefetchGap + 10*time.Millisecond)
assert.True(t, g.allow(), "allow after the gap elapses must pass again")
assert.False(t, g.allow(), "post-recovery allow must re-arm the gate")
}

func newTestServer(tag string, isLantern, hardDemoted bool, updatedAt time.Time) *servers.Server {
srv := &servers.Server{
Tag: tag,
IsLantern: isLantern,
}
if hardDemoted || !updatedAt.IsZero() {
srv.SelectionHistory = &servers.SelectionHistory{
HardDemoted: hardDemoted,
UpdatedAt: updatedAt,
}
}
return srv
}

func TestLanternServersToEvict(t *testing.T) {
baseTime := time.Unix(0, 0).UTC()

tests := []struct {
name string
existing []*servers.Server
incoming int
limit int
want []string
}{
{
name: "evicts only hard-demoted lantern servers",
existing: []*servers.Server{
newTestServer("demoted", true, true, baseTime),
newTestServer("working", true, false, baseTime),
newTestServer("users-demoted", false, true, baseTime),
},
limit: 60,
want: []string{"demoted"},
},
{
name: "hard-demoted lantern server is evicted regardless of incoming config",
existing: []*servers.Server{
newTestServer("demoted", true, true, baseTime),
},
incoming: 1,
limit: 60,
want: []string{"demoted"},
},
{
name: "under the limit nothing is evicted",
existing: []*servers.Server{
newTestServer("a", true, false, baseTime),
newTestServer("b", true, false, baseTime),
},
incoming: 2,
limit: 60,
},
{
name: "over the limit evicts oldest working servers and keeps the newest",
existing: []*servers.Server{
newTestServer("old", true, false, baseTime.Add(1*time.Hour)),
newTestServer("mid", true, false, baseTime.Add(2*time.Hour)),
newTestServer("new", true, false, baseTime.Add(3*time.Hour)),
},
incoming: 1,
limit: 3,
want: []string{"old"},
},
{
name: "incoming at the limit evicts all existing working servers",
existing: []*servers.Server{
newTestServer("a", true, false, baseTime.Add(1*time.Hour)),
newTestServer("b", true, false, baseTime.Add(2*time.Hour)),
},
incoming: 3,
limit: 3,
want: []string{"a", "b"},
},
{
name: "server with no selection history sorts oldest",
existing: []*servers.Server{
newTestServer("no-history", true, false, time.Time{}),
newTestServer("probed", true, false, baseTime.Add(5*time.Hour)),
},
incoming: 1,
limit: 2,
want: []string{"no-history"},
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got := lanternServersToEvict(tt.existing, tt.incoming, tt.limit)
assert.ElementsMatch(t, tt.want, got)
})
}
}
52 changes: 44 additions & 8 deletions cmd/lantern/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"slices"
"strings"

C "github.com/getlantern/common"
Expand All @@ -24,6 +25,7 @@ type ServersCmd struct {
type ServersListCmd struct {
Latency bool `arg:"--latency" help:"include latest latency from selection history"`
JSON bool `arg:"--json" help:"output JSON"`
Limit int `arg:"--limit" help:"limit number of servers to list"`
}

type ServersShowCmd struct {
Expand Down Expand Up @@ -92,9 +94,9 @@ func runServers(ctx context.Context, c *ipc.Client, cmd *ServersCmd) error {
case cmd.PrivateServer != nil:
return runPrivateServer(ctx, c, cmd.PrivateServer)
case cmd.List != nil:
return serversList(ctx, c, cmd.List.Latency, cmd.List.JSON)
return serversList(ctx, c, cmd.List.Latency, cmd.List.JSON, cmd.List.Limit)
default:
return serversList(ctx, c, false, false)
return serversList(ctx, c, false, false, 0)
}
}

Expand All @@ -116,11 +118,42 @@ func runPrivateServer(ctx context.Context, c *ipc.Client, cmd *PrivateServerCmd)
}
}

func serversList(ctx context.Context, c *ipc.Client, showLatency, asJSON bool) error {
func serversList(ctx context.Context, c *ipc.Client, showLatency, asJSON bool, limit int) error {
Comment thread
garmr-ulfr marked this conversation as resolved.
srvs, err := c.Servers(ctx)
if err != nil {
return err
}
total := len(srvs)

latestDelay := func(s *servers.Server) uint32 {
if s.SelectionHistory != nil {
return s.SelectionHistory.LatestSuccessDelay()
}
return 0
}
slices.SortFunc(srvs, func(a, b *servers.Server) int {
ad := latestDelay(a)
bd := latestDelay(b)

switch {
case ad == 0 && bd == 0:
return 0
case ad == 0:
return 1
case bd == 0:
return -1
case ad < bd:
return -1
case ad > bd:
return 1
default:
return 0
}
})
Comment thread
garmr-ulfr marked this conversation as resolved.
if limit > 0 && limit < len(srvs) {
srvs = srvs[:limit]
}

if asJSON {
out := make([]ServerListEntry, 0, len(srvs))
for _, s := range srvs {
Expand All @@ -133,13 +166,14 @@ func serversList(ctx context.Context, c *ipc.Client, showLatency, asJSON bool) e
}
return printJSON(out)
}
if len(srvs) == 0 {
if total == 0 {
fmt.Println("No servers available")
return nil
}
for _, s := range srvs {
printServerEntry(s, showLatency)
}
fmt.Printf("%d total server(s)\n", total)
return nil
}

Expand All @@ -152,11 +186,13 @@ func printServerEntry(s *servers.Server, showLatency bool) {
fmt.Println()
return
}
if d := s.SelectionHistory.LatestSuccessDelay(); d > 0 {
fmt.Printf(" (%dms)\n", d)
} else {
fmt.Println(" (n/a)")
if s.SelectionHistory != nil {
if d := s.SelectionHistory.LatestSuccessDelay(); d > 0 {
fmt.Printf(" (%dms)\n", d)
return
}
}
fmt.Println(" (n/a)")
}

func serversGet(ctx context.Context, c *ipc.Client, tag string) error {
Expand Down
Loading
Loading