Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.

115 changes: 111 additions & 4 deletions backend/radiance.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ func (r *LocalBackend) Start() {
span.End() // point-in-time marker — config was received at this timestamp
}
}
if err := r.setServers(list, true); err != nil {
if err := r.updateServers(list); err != nil {
slog.Error("setting servers in manager", "error", err)
}
Comment thread
Copilot marked this conversation as resolved.
if err := r.RunOfflineURLTests(); err != nil && !errors.Is(err, vpn.ErrTunnelAlreadyConnected) {
Expand Down Expand Up @@ -613,9 +613,46 @@ 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 {
incomingTags := serverTagSet(list.Servers)
existing := r.srvManager.AllServers()
tagsToEvict := lanternServersToEvict(
existing,
incomingTags,
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)
}
}

// 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
})

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)
}
Comment thread
garmr-ulfr marked this conversation as resolved.
Outdated
// 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 +666,76 @@ 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. Refreshed tags are skipped so AddServers can
// update them in place. Hard-demoted servers are removed first. Remaining
// candidates are evicted oldest-first by SelectionHistory.UpdatedAt; missing
// history sorts oldest.
func lanternServersToEvict(
existing []*servers.Server,
incomingTags map[string]struct{},
incomingCount, limit int,
) []string {
tagsToEvict := make([]string, 0)
retentionCandidates := make([]*servers.Server, 0, len(existing))

for _, srv := range existing {
if !isRetainedLanternServer(srv, incomingTags) {
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 isRetainedLanternServer(srv *servers.Server, incomingTags map[string]struct{}) bool {
if !srv.IsLantern {
return false
}
_, refreshed := incomingTags[srv.Tag]
return !refreshed
}

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
129 changes: 129 additions & 0 deletions backend/radiance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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 tagSet(tags ...string) map[string]struct{} {
set := make(map[string]struct{}, len(tags))
for _, tag := range tags {
set[tag] = struct{}{}
}
return set
}

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

tests := []struct {
name string
existing []*servers.Server
incomingTags map[string]struct{}
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: "refreshed tag is left for AddServers even if hard-demoted",
existing: []*servers.Server{
newTestServer("demoted", true, true, baseTime),
},
incomingTags: tagSet("demoted"),
incoming: 1,
limit: 60,
},
{
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.incomingTags, tt.incoming, tt.limit)
assert.ElementsMatch(t, tt.want, got)
})
}
}
33 changes: 29 additions & 4 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,7 +118,7 @@ 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
Expand All @@ -137,9 +139,32 @@ func serversList(ctx context.Context, c *ipc.Client, showLatency, asJSON bool) e
fmt.Println("No servers available")
return nil
}
for _, s := range srvs {
slices.SortFunc(srvs, func(a, b *servers.Server) int {
ad := a.SelectionHistory.LatestSuccessDelay()
bd := b.SelectionHistory.LatestSuccessDelay()

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.
for i, s := range srvs {
if limit > 0 && i >= limit {
break
}
printServerEntry(s, showLatency)
Comment thread
garmr-ulfr marked this conversation as resolved.
Outdated
}
fmt.Printf("%d server(s)\n", len(srvs))
return nil
}
Comment thread
garmr-ulfr marked this conversation as resolved.
Outdated

Expand Down
2 changes: 1 addition & 1 deletion cmd/lanternd/lanternd.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func spawnChild(args []string, dataPath, logPath, logLevel string) (*childProces
go func() {
defer stdoutPipe.Close()
var w io.Writer = os.Stdout
if h, ok := logger.Handler().(*rlog.Handler); ok {
if h, ok := logger.Handler().(rlog.Handler); ok {
w = h.Writer()
}
Comment thread
garmr-ulfr marked this conversation as resolved.
Outdated
scanner := bufio.NewScanner(stdoutPipe)
Expand Down
4 changes: 3 additions & 1 deletion events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ package events

import (
"context"
"fmt"
"log/slog"
"reflect"
"sync"
Expand Down Expand Up @@ -126,7 +127,8 @@ func Emit[T Event](evt T) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("Panic in event callback", "error", r)
evtStr := fmt.Sprintf("%T{%+v}", evt, evt)
slog.Error("Panic in event callback", "error", r, "event", evtStr)
}
Comment thread
garmr-ulfr marked this conversation as resolved.
}()
cb(evt)
Expand Down
Loading
Loading