From cc242250be3850d9a4effe8ebaf2140e7315e8b2 Mon Sep 17 00:00:00 2001 From: garmr-ulfr Date: Fri, 22 May 2026 15:35:17 -0700 Subject: [PATCH 1/4] backend, vpn: integrate lantern-box MutableAutoSelect outbound Adapts radiance to consume the new MutableAutoSelect outbound from lantern-box (PR #266). Replaces the prior MutableURLTest selector with one that distinguishes probe-URL health from user-traffic health. - Build `auto` as TypeMutableAutoSelect with per-tag bandit URL overrides. The demote / dedupe / proved-read knobs inherit documented lantern-box defaults. - Bridge the group's ExhaustionSignal onto a vpn.ExhaustionEvent so subscribers can react without the tunnel knowing about them. LocalBackend subscribes and refetches /config-new through an exhaustionGate that rate-limits to one minute. - Register a lantern-box AutoSelectHistoryStorage on the tunnel and run a ticker-coalesced flush listener that mirrors per-tag TagHistory snapshots (probe scalars + user_failures window) into the persisted servers file. Lifecycle tied to Connected/ Disconnected VPN status events. - Seed cold-start ranking via the offline URL-test path: results are shaped as TagHistory entries and persisted so the next tunnel start picks them up via SelectionHistorySeed. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- backend/exhaustion_test.go | 31 ++++++++ backend/radiance.go | 156 +++++++++++++++++++++++-------------- backend/radiance_test.go | 8 -- cmd/lantern/monitor.go | 98 +++++++++++++++++------ cmd/lantern/servers.go | 22 +++--- cmd/lantern/vpn.go | 15 ++-- config/config.go | 4 +- go.mod | 2 +- go.sum | 4 +- servers/manager.go | 60 +++++++------- vpn/boxoptions.go | 31 ++++---- vpn/boxoptions_test.go | 6 +- vpn/clash.go | 16 ++-- vpn/session_history.go | 14 ++-- vpn/tunnel.go | 79 +++++++++++++------ vpn/types.go | 13 +++- vpn/vpn.go | 33 ++++---- 18 files changed, 378 insertions(+), 216 deletions(-) create mode 100644 backend/exhaustion_test.go delete mode 100644 backend/radiance_test.go diff --git a/AGENTS.md b/AGENTS.md index 1f582f0a..a129dd59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ cancelRequests() ```go // BAD — restates name, generic preamble, narrates the code -// updateURLTestListener manages the lifecycle of the URL test result listener +// updateSelectionHistoryListener manages the lifecycle of the selection history listener // across VPN status changes. Connected always re-attaches (canceling any // existing listener) so a stale event still leaves the listener bound to // the live storage. diff --git a/backend/exhaustion_test.go b/backend/exhaustion_test.go new file mode 100644 index 00000000..b51f3f64 --- /dev/null +++ b/backend/exhaustion_test.go @@ -0,0 +1,31 @@ +package backend + +import ( + "testing" + "time" +) + +func TestExhaustionGate_AllowRateLimitsBelowGap(t *testing.T) { + prev := defaultExhaustionRefetchGap + defaultExhaustionRefetchGap = 50 * time.Millisecond + t.Cleanup(func() { defaultExhaustionRefetchGap = prev }) + + var g exhaustionGate + if !g.allow() { + t.Fatal("first allow must pass on a zero gate") + } + if g.allow() { + t.Error("second allow inside the gap must be rate-limited") + } + if g.allow() { + t.Error("third allow inside the gap must still be rate-limited") + } + + time.Sleep(defaultExhaustionRefetchGap + 10*time.Millisecond) + if !g.allow() { + t.Error("allow after the gap elapses must pass again") + } + if g.allow() { + t.Error("post-recovery allow must re-arm the gate") + } +} diff --git a/backend/radiance.go b/backend/radiance.go index ba8d5bd3..c90a6261 100644 --- a/backend/radiance.go +++ b/backend/radiance.go @@ -40,7 +40,7 @@ import ( "github.com/getlantern/radiance/traces" "github.com/getlantern/radiance/vpn" - "github.com/sagernet/sing-box/adapter" + lbA "github.com/getlantern/lantern-box/adapter" "github.com/sagernet/sing-box/option" ) @@ -75,8 +75,10 @@ type LocalBackend struct { stopDataCap context.CancelFunc dataCapMu sync.Mutex - stopURLTestListener context.CancelFunc - urlTestMu sync.Mutex + stopSelectionHistoryListener context.CancelFunc + selectionHistoryMu sync.Mutex + + exhaustionGate exhaustionGate } type Options struct { @@ -287,10 +289,10 @@ func (r *LocalBackend) Start() { // 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 URL test cycle for them via - // MutableURLTest.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 URLTest selector. + // 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. slog.Error("Failed to run offline URL tests after config update", "error", err) } }) @@ -322,7 +324,10 @@ func (r *LocalBackend) startVPNStatusListeners() { r.updateDataCapStream(evt.Status) }) events.SubscribeContext(r.ctx, func(evt vpn.StatusUpdateEvent) { - r.updateURLTestListener(evt.Status) + r.updateSelectionHistoryListener(evt.Status) + }) + events.SubscribeContext(r.ctx, func(vpn.ExhaustionEvent) { + r.refetchOnExhaustion() }) events.SubscribeContext(r.ctx, func(evt vpn.StatusUpdateEvent) { switch evt.Status { @@ -417,7 +422,7 @@ func baseIssueAttachments() []string { // UpdateConfig forces an immediate fetch of the latest configuration. It returns // [config.ErrConfigFetchDisabled] if config fetching is disabled in settings. func (r *LocalBackend) UpdateConfig() error { - return r.confHandler.Update() + return r.confHandler.Fetch() } // Features returns the features available in the current configuration, returned from the server in the @@ -640,78 +645,79 @@ func (r *LocalBackend) RevokePrivateServerInvite(ip string, port int, accessToke return r.srvManager.RevokePrivateServerInvite(ip, port, accessToken, inviteName) } -// urlTestFlushInterval bounds how often URL test results are written back to the servers manager -// (and to disk). URL test cycles run on the order of minutes and notify per-result, so coalescing -// into a periodic flush avoids re-marshalling and re-writing the servers file for each parallel result. -const urlTestFlushInterval = 5 * time.Second +const selectionHistoryFlushInterval = 5 * time.Second -func (r *LocalBackend) updateURLTestListener(status vpn.VPNStatus) { - r.urlTestMu.Lock() - defer r.urlTestMu.Unlock() - // Status events are dispatched in unordered goroutines, so reacting to - // intermediate statuses (Connecting, Disconnecting, Restarting) risks a - // stale handler tearing down a listener a concurrent Connected handler - // just attached to the new tunnel. +func (r *LocalBackend) updateSelectionHistoryListener(status vpn.VPNStatus) { + r.selectionHistoryMu.Lock() + defer r.selectionHistoryMu.Unlock() switch status { case vpn.Connected: - if r.stopURLTestListener != nil { - r.stopURLTestListener() - r.stopURLTestListener = nil + if r.stopSelectionHistoryListener != nil { + r.stopSelectionHistoryListener() + r.stopSelectionHistoryListener = nil } storage := r.vpnClient.HistoryStorage() if storage == nil { return } ctx, cancel := context.WithCancel(r.ctx) - r.stopURLTestListener = cancel + r.stopSelectionHistoryListener = cancel hook := make(chan struct{}, 1) - storage.SetHook(hook) - go r.runURLTestListener(ctx, storage, hook) - slog.Debug("Started URL test result listener") + storage.SetHook(func(string) { + // Per-tag granularity isn't useful — flushSelectionHistory + // iterates every server. Non-blocking send so storage + // writes never block on a slow flush. + select { + case hook <- struct{}{}: + default: + } + }) + go r.runSelectionHistoryListener(ctx, storage, hook) + slog.Debug("Started selection history listener") case vpn.Disconnected, vpn.ErrorStatus: - if r.stopURLTestListener != nil { - r.stopURLTestListener() - r.stopURLTestListener = nil - slog.Debug("Stopped URL test result listener") + if r.stopSelectionHistoryListener != nil { + r.stopSelectionHistoryListener() + r.stopSelectionHistoryListener = nil + slog.Debug("Stopped selection history listener") } } } -// runURLTestListener coalesces per-result hook notifications into a periodic flush so the servers -// file isn't rewritten for each parallel URL test completion. A final flush runs on shutdown so any -// results that arrived since the last tick are persisted. -func (r *LocalBackend) runURLTestListener(ctx context.Context, storage vpn.URLTestHistoryStorage, hook <-chan struct{}) { - ticker := time.NewTicker(urlTestFlushInterval) +// runSelectionHistoryListener coalesces per-result hook notifications into a periodic flush so the +// servers file isn't rewritten for each parallel probe completion. A final flush runs on shutdown so +// any results that arrived since the last tick are persisted. +func (r *LocalBackend) runSelectionHistoryListener(ctx context.Context, storage vpn.AutoSelectHistoryStorage, hook <-chan struct{}) { + ticker := time.NewTicker(selectionHistoryFlushInterval) defer ticker.Stop() dirty := true // start dirty so we persist any results that arrived before the listener started for { select { case <-ctx.Done(): if dirty { - r.flushURLTestResults(storage) + r.flushSelectionHistory(storage) } return case <-hook: dirty = true case <-ticker.C: if dirty { - r.flushURLTestResults(storage) + r.flushSelectionHistory(storage) dirty = false } } } } -func (r *LocalBackend) flushURLTestResults(storage vpn.URLTestHistoryStorage) { - results := make(map[string]servers.URLTestResult) +func (r *LocalBackend) flushSelectionHistory(storage vpn.AutoSelectHistoryStorage) { + results := make(map[string]servers.SelectionHistory) for _, srv := range r.srvManager.AllServers() { - if h := storage.LoadURLTestHistory(srv.Tag); h != nil { - results[srv.Tag] = servers.URLTestResult{Delay: h.Delay, Time: h.Time} + if h := storage.Load(srv.Tag); h != nil { + results[srv.Tag] = *h } } if len(results) > 0 { - if err := r.srvManager.UpdateURLTestResults(results); err != nil { - slog.Warn("Failed to persist URL test results", "error", err) + if err := r.srvManager.UpdateSelectionHistory(results); err != nil { + slog.Warn("Failed to persist selection history", "error", err) } } } @@ -751,7 +757,6 @@ func (r *LocalBackend) getBoxOptions() vpn.BoxOptions { if cfg != nil { bOptions.Options = cfg.Options bOptions.BanditURLOverrides = cfg.BanditURLOverrides - bOptions.BanditThroughputURL = cfg.BanditThroughputURL if settings.GetBool(settings.SmartRoutingKey) { bOptions.SmartRouting = cfg.SmartRouting } @@ -759,7 +764,7 @@ func (r *LocalBackend) getBoxOptions() vpn.BoxOptions { bOptions.AdBlock = cfg.AdBlock } } - seed := make(map[string]adapter.URLTestHistory) + seed := make(map[string]lbA.TagHistory) for _, srv := range r.srvManager.AllServers() { if !srv.IsLantern { switch opts := srv.Options.(type) { @@ -769,15 +774,12 @@ func (r *LocalBackend) getBoxOptions() vpn.BoxOptions { bOptions.Options.Endpoints = append(bOptions.Options.Endpoints, opts) } } - if srv.URLTestResult != nil { - seed[srv.Tag] = adapter.URLTestHistory{ - Time: srv.URLTestResult.Time, - Delay: srv.URLTestResult.Delay, - } + if srv.SelectionHistory != nil { + seed[srv.Tag] = *srv.SelectionHistory } } if len(seed) > 0 { - bOptions.URLTestSeed = seed + bOptions.SelectionHistorySeed = seed } return bOptions } @@ -951,13 +953,21 @@ func (r *LocalBackend) RunOfflineURLTests() error { return err } now := time.Now() - urlResults := make(map[string]servers.URLTestResult, len(results)) + histories := make(map[string]servers.SelectionHistory, len(results)) for tag, delay := range results { - urlResults[tag] = servers.URLTestResult{Delay: delay, Time: now} + // Offline pre-warm produces only a single success delay per tag; + // shape it as a probe-success snapshot so the production tunnel's + // AutoSelectHistoryStorage can seed cold-start ranking from it + // without further translation. + histories[tag] = lbA.TagHistory{ + LastSuccessDelayMs: uint32(delay), + LastOutcomeAt: now, + UpdatedAt: now, + } } - if len(urlResults) > 0 { - if err := r.srvManager.UpdateURLTestResults(urlResults); err != nil { - slog.Warn("Failed to persist offline URL test results", "error", err) + if len(histories) > 0 { + if err := r.srvManager.UpdateSelectionHistory(histories); err != nil { + slog.Warn("Failed to persist offline selection history", "error", err) } selected, err := r.vpnClient.CurrentAutoSelectedServer() if err != nil { @@ -969,6 +979,38 @@ func (r *LocalBackend) RunOfflineURLTests() error { return nil } +// defaultExhaustionRefetchGap rate-limits exhaustion-driven refetches +// so a misbehaving config handler can't hammer /config-new. +var defaultExhaustionRefetchGap = time.Minute + +// exhaustionGate rate-limits exhaustion-driven refetches. lastAt is +// recorded before the fetch runs so a failing fetcher doesn't +// tight-loop. +type exhaustionGate struct { + mu sync.Mutex + lastAt time.Time +} + +func (g *exhaustionGate) allow() bool { + g.mu.Lock() + defer g.mu.Unlock() + now := time.Now() + if !g.lastAt.IsZero() && now.Sub(g.lastAt) < defaultExhaustionRefetchGap { + return false + } + g.lastAt = now + return true +} + +func (r *LocalBackend) refetchOnExhaustion() { + if !r.exhaustionGate.allow() { + return + } + if err := r.confHandler.Fetch(); err != nil { + slog.Warn("MutableAutoSelect exhaustion refetch failed", "error", err) + } +} + ////////////////// // Split Tunnel // ///////////////// diff --git a/backend/radiance_test.go b/backend/radiance_test.go deleted file mode 100644 index dd6eaa62..00000000 --- a/backend/radiance_test.go +++ /dev/null @@ -1,8 +0,0 @@ -package backend - -import ( - "testing" -) - -func TestBackend(t *testing.T) { -} diff --git a/cmd/lantern/monitor.go b/cmd/lantern/monitor.go index 5892be6f..e1e65683 100644 --- a/cmd/lantern/monitor.go +++ b/cmd/lantern/monitor.go @@ -38,7 +38,7 @@ const ( type MonitorCmd struct { Interval time.Duration `arg:"-i,--interval" default:"1s" help:"refresh interval"` - Pool int `arg:"--pool" default:"5" help:"number of fastest servers to list; 0 to omit pool summary"` + Pool int `arg:"--pool" default:"5" help:"number of pool servers to list (tested first, by ascending latency); 0 to omit pool summary"` History int `arg:"--history" default:"3" help:"number of recent sessions to include; 0 to omit"` Logs int `arg:"--logs" default:"5" help:"number of recent warn/error log entries to display (totals always shown); 0 hides entries"` JSON bool `arg:"--json" help:"emit one JSON snapshot per refresh"` @@ -71,15 +71,16 @@ type logCounts struct { type poolSummary struct { Total int `json:"total"` Tested int `json:"tested"` - Fastest []serverLatency `json:"fastest,omitempty"` + Servers []serverLatency `json:"servers,omitempty"` } type serverLatency struct { - Tag string `json:"tag"` - Type string `json:"type,omitempty"` - Location string `json:"location,omitempty"` - DelayMs uint16 `json:"delay_ms"` - TestedAt time.Time `json:"tested_at"` + Tag string `json:"tag"` + Type string `json:"type,omitempty"` + Location string `json:"location,omitempty"` + DelayMs uint32 `json:"delay_ms"` + TestedAt time.Time `json:"tested_at"` + History *servers.SelectionHistory `json:"history,omitempty"` } type logEvent struct { @@ -224,25 +225,38 @@ func fetchMonitor(ctx context.Context, c *ipc.Client, cmd *MonitorCmd, snap *mon func summarizePool(srvs []*servers.Server, top int) *poolSummary { out := &poolSummary{Total: len(srvs)} - tested := make([]serverLatency, 0, len(srvs)) + entries := make([]serverLatency, 0, len(srvs)) for _, s := range srvs { - if s == nil || s.URLTestResult == nil { + if s == nil { continue } - tested = append(tested, serverLatency{ + e := serverLatency{ Tag: s.Tag, Type: s.Type, Location: joinNonEmpty(", ", s.Location.City, s.Location.Country), - DelayMs: s.URLTestResult.Delay, - TestedAt: s.URLTestResult.Time, - }) + } + if s.SelectionHistory != nil { + out.Tested++ + e.DelayMs = s.SelectionHistory.LatestSuccessDelay() + e.TestedAt = s.SelectionHistory.LatestSuccessTime() + e.History = s.SelectionHistory + } + entries = append(entries, e) } - out.Tested = len(tested) - sort.Slice(tested, func(i, j int) bool { return tested[i].DelayMs < tested[j].DelayMs }) - if top > len(tested) { - top = len(tested) + sort.Slice(entries, func(i, j int) bool { + ti, tj := entries[i].History != nil, entries[j].History != nil + if ti != tj { + return ti + } + if ti { + return entries[i].DelayMs < entries[j].DelayMs + } + return entries[i].Tag < entries[j].Tag + }) + if top > len(entries) { + top = len(entries) } - out.Fastest = tested[:top] + out.Servers = entries[:top] return out } @@ -397,20 +411,47 @@ func renderServerPool(w io.Writer, p *poolSummary) { } fmt.Fprintf(w, "Server pool: %d total, %d with recent test%s", p.Total, p.Tested, eol) now := time.Now() - for _, s := range p.Fastest { + for _, s := range p.Servers { name := formatTag(s.Tag) if s.Location != "" { name = fmt.Sprintf("%s [%s]", name, s.Location) } - age := "—" - if !s.TestedAt.IsZero() { - age = now.Sub(s.TestedAt).Truncate(time.Second).String() + " ago" + delay := " n/a" + age := "untested" + if s.History != nil { + delay = fmt.Sprintf("%5dms", s.DelayMs) + age = "tested " + if s.TestedAt.IsZero() { + age += "—" + } else { + age += now.Sub(s.TestedAt).Truncate(time.Second).String() + " ago" + } + } + fmt.Fprintf(w, " %s %s (%s)%s", delay, name, age, eol) + if line := historyLine(s.History); line != "" { + fmt.Fprintf(w, " %s%s", line, eol) } - fmt.Fprintf(w, " %5dms %s (tested %s)%s", s.DelayMs, name, age, eol) } io.WriteString(w, eol) } +func historyLine(h *servers.SelectionHistory) string { + if h == nil { + return "" + } + parts := make([]string, 0, 3) + if h.LastSuccessDelayMs > 0 { + parts = append(parts, fmt.Sprintf("last: %dms", h.LastSuccessDelayMs)) + } + if h.ConsecutiveFailures > 0 { + parts = append(parts, fmt.Sprintf("consec: %d", h.ConsecutiveFailures)) + } + if n := len(h.UserFailures); n > 0 { + parts = append(parts, fmt.Sprintf("user fail: %d", n)) + } + return strings.Join(parts, " · ") +} + func renderRecentLogs(w io.Writer, logs []logEvent, counts logCounts) { fmt.Fprintf(w, "Recent warn/error logs: %d warn, %d error%s", counts.Warn, counts.Error, eol) if len(logs) == 0 { @@ -592,12 +633,23 @@ func outboundTags(s vpn.ThroughputSnapshot) []string { } tags := make([]string, 0, len(set)) for tag := range set { + if isGroupTag(tag) { + continue + } tags = append(tags, tag) } sort.Strings(tags) return tags } +// isGroupTag reports whether tag names a selector/auto-select group rather +// than a leaf outbound. sing-box's clash tracker can attribute a connection +// to the group tag in addition to the leaf, so groups must be filtered out +// of per-outbound throughput tags to avoid phantom entries. +func isGroupTag(tag string) bool { + return tag == vpn.AutoSelectTag || tag == vpn.ManualSelectTag +} + type monitorState struct { mu sync.Mutex dataCap atomic.Pointer[account.DataCapInfo] diff --git a/cmd/lantern/servers.go b/cmd/lantern/servers.go index f592e0a7..37743546 100644 --- a/cmd/lantern/servers.go +++ b/cmd/lantern/servers.go @@ -22,7 +22,7 @@ type ServersCmd struct { } type ServersListCmd struct { - Latency bool `arg:"--latency" help:"include URL test latency results"` + Latency bool `arg:"--latency" help:"include latest latency from selection history"` JSON bool `arg:"--json" help:"output JSON"` } @@ -45,10 +45,10 @@ type ServersRemoveCmd struct { // ServerListEntry represents a server in the list output. type ServerListEntry struct { - Tag string `json:"tag"` - Type string `json:"type"` - Location C.ServerLocation `json:"location,omitempty"` - URLTestResult *servers.URLTestResult `json:"urlTestResult,omitempty"` + Tag string `json:"tag"` + Type string `json:"type"` + Location C.ServerLocation `json:"location,omitempty"` + SelectionHistory *servers.SelectionHistory `json:"selection_history,omitempty"` } type PrivateServerCmd struct { @@ -125,10 +125,10 @@ func serversList(ctx context.Context, c *ipc.Client, showLatency, asJSON bool) e out := make([]ServerListEntry, 0, len(srvs)) for _, s := range srvs { out = append(out, ServerListEntry{ - Tag: s.Tag, - Type: s.Type, - Location: s.Location, - URLTestResult: s.URLTestResult, + Tag: s.Tag, + Type: s.Type, + Location: s.Location, + SelectionHistory: s.SelectionHistory, }) } return printJSON(out) @@ -152,8 +152,8 @@ func printServerEntry(s *servers.Server, showLatency bool) { fmt.Println() return } - if s.URLTestResult != nil { - fmt.Printf(" (%dms)\n", s.URLTestResult.Delay) + if d := s.SelectionHistory.LatestSuccessDelay(); d > 0 { + fmt.Printf(" (%dms)\n", d) } else { fmt.Println(" (n/a)") } diff --git a/cmd/lantern/vpn.go b/cmd/lantern/vpn.go index 4e6c0549..6e3a85ef 100644 --- a/cmd/lantern/vpn.go +++ b/cmd/lantern/vpn.go @@ -109,13 +109,16 @@ func printThroughput(s vpn.ThroughputSnapshot) { for tag := range s.ActivePerOutbound { tagSet[tag] = struct{}{} } - if len(tagSet) == 0 { - return - } tags := make([]string, 0, len(tagSet)) for tag := range tagSet { + if isGroupTag(tag) { + continue + } tags = append(tags, tag) } + if len(tags) == 0 { + return + } sort.Strings(tags) fmt.Print("\r\n") for _, tag := range tags { @@ -159,7 +162,7 @@ type statusSnapshot struct { Status vpn.VPNStatus `json:"status"` Server string `json:"server,omitempty"` Location string `json:"location,omitempty"` - LatencyMs uint16 `json:"latency_ms,omitempty"` + LatencyMs uint32 `json:"latency_ms,omitempty"` IP string `json:"ip,omitempty"` } @@ -173,9 +176,7 @@ func fetchStatus(ctx context.Context, c *ipc.Client) (statusSnapshot, error) { if sel, exists, err := c.SelectedServer(ctx); err == nil && exists && sel != nil { snap.Server = sel.Tag snap.Location = joinNonEmpty(", ", sel.Location.City, sel.Location.Country) - if sel.URLTestResult != nil { - snap.LatencyMs = sel.URLTestResult.Delay - } + snap.LatencyMs = sel.SelectionHistory.LatestSuccessDelay() } } tctx, tcancel := context.WithTimeout(ctx, 5*time.Second) diff --git a/config/config.go b/config/config.go index c5fffd7c..e2b05341 100644 --- a/config/config.go +++ b/config/config.go @@ -306,9 +306,9 @@ func (ch *ConfigHandler) fetchLoop(defaultPollInterval time.Duration) { } } -// Update immediately fetches the latest config. It returns [ErrConfigFetchDisabled] +// Fetch immediately fetches the latest config. It returns [ErrConfigFetchDisabled] // if config fetching is disabled in settings. -func (ch *ConfigHandler) Update() error { +func (ch *ConfigHandler) Fetch() error { if settings.GetBool(settings.ConfigFetchDisabledKey) { return ErrConfigFetchDisabled } diff --git a/go.mod b/go.mod index 09000312..33042288 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03 - github.com/getlantern/lantern-box v0.0.82 + github.com/getlantern/lantern-box v0.0.83-0.20260522034210-c49bbbfbd35f github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb diff --git a/go.sum b/go.sum index 738b3197..376a8a78 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03 h1:dUTN7mnTTBcSvsURNs1rTlyKrD1uXUEPqxEZDfl+hb4= github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.82 h1:hCXqpCxLOQNxYtQZQDYVh3aj3t8NqSBqJjCn2mIBtK0= -github.com/getlantern/lantern-box v0.0.82/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= +github.com/getlantern/lantern-box v0.0.83-0.20260522034210-c49bbbfbd35f h1:KzF+b1MXN/pOKKQ7j1NU4H56JWAHYopE9ztS+XCWpN8= +github.com/getlantern/lantern-box v0.0.83-0.20260522034210-c49bbbfbd35f/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= diff --git a/servers/manager.go b/servers/manager.go index 0c6302f6..0e8118c4 100644 --- a/servers/manager.go +++ b/servers/manager.go @@ -22,6 +22,7 @@ import ( "time" box "github.com/getlantern/lantern-box" + lbA "github.com/getlantern/lantern-box/adapter" "github.com/hashicorp/go-retryablehttp" "go.opentelemetry.io/otel" @@ -85,37 +86,37 @@ type ServerCredentials struct { } type Server struct { - Tag string `json:"tag"` - Type string `json:"type"` - IsLantern bool `json:"isLantern"` - Options any `json:"options"` - Location C.ServerLocation `json:"location,omitempty"` - Credentials *ServerCredentials `json:"credentials,omitempty"` - URLTestResult *URLTestResult `json:"urlTestResult,omitempty"` + Tag string `json:"tag"` + Type string `json:"type"` + IsLantern bool `json:"isLantern"` + Options any `json:"options"` + Location C.ServerLocation `json:"location,omitempty"` + Credentials *ServerCredentials `json:"credentials,omitempty"` + SelectionHistory *SelectionHistory `json:"selection_history,omitempty"` } // serverJSON is the on-wire representation of a Server. The Options field is split into // explicit Outbound/Endpoint fields so that the sing-box context-aware JSON marshaler can // properly serialize/deserialize the typed options (e.g. SamizdatOutboundOptions). type serverJSON struct { - Tag string `json:"tag"` - Type string `json:"type"` - IsLantern bool `json:"isLantern"` - Outbound *option.Outbound `json:"outbound,omitempty"` - Endpoint *option.Endpoint `json:"endpoint,omitempty"` - Location C.ServerLocation `json:"location,omitempty"` - Credentials *ServerCredentials `json:"credentials,omitempty"` - URLTestResult *URLTestResult `json:"urlTestResult,omitempty"` + Tag string `json:"tag"` + Type string `json:"type"` + IsLantern bool `json:"isLantern"` + Outbound *option.Outbound `json:"outbound,omitempty"` + Endpoint *option.Endpoint `json:"endpoint,omitempty"` + Location C.ServerLocation `json:"location,omitempty"` + Credentials *ServerCredentials `json:"credentials,omitempty"` + SelectionHistory *SelectionHistory `json:"selection_history,omitempty"` } func (s Server) MarshalJSON() ([]byte, error) { sj := serverJSON{ - Tag: s.Tag, - Type: s.Type, - IsLantern: s.IsLantern, - Location: s.Location, - Credentials: s.Credentials, - URLTestResult: s.URLTestResult, + Tag: s.Tag, + Type: s.Type, + IsLantern: s.IsLantern, + Location: s.Location, + Credentials: s.Credentials, + SelectionHistory: s.SelectionHistory, } switch opts := s.Options.(type) { case option.Outbound: @@ -136,7 +137,7 @@ func (s *Server) UnmarshalJSON(data []byte) error { s.IsLantern = sj.IsLantern s.Location = sj.Location s.Credentials = sj.Credentials - s.URLTestResult = sj.URLTestResult + s.SelectionHistory = sj.SelectionHistory if sj.Outbound != nil { s.Options = *sj.Outbound } else if sj.Endpoint != nil { @@ -252,22 +253,19 @@ func (m *Manager) AllServers() []*Server { return result } -// URLTestResult holds the result of a single URL test. -type URLTestResult struct { - Delay uint16 `json:"delay"` - Time time.Time `json:"time"` -} +// SelectionHistory is the on-disk shape for a server's selection history. +type SelectionHistory = lbA.TagHistory -// UpdateURLTestResults updates the URL test results for servers matching the -// provided tags and persists the change to disk. -func (m *Manager) UpdateURLTestResults(results map[string]URLTestResult) error { +// UpdateSelectionHistory updates the selection history for servers +// matching the provided tags and persists the change to disk. +func (m *Manager) UpdateSelectionHistory(results map[string]SelectionHistory) error { func() { m.access.Lock() defer m.access.Unlock() for tag, result := range results { if srv, exists := m.servers[tag]; exists { r := result - srv.URLTestResult = &r + srv.SelectionHistory = &r } } }() diff --git a/vpn/boxoptions.go b/vpn/boxoptions.go index cda33b58..c3465f68 100644 --- a/vpn/boxoptions.go +++ b/vpn/boxoptions.go @@ -20,9 +20,9 @@ import ( lcommon "github.com/getlantern/common" box "github.com/getlantern/lantern-box" + lbA "github.com/getlantern/lantern-box/adapter" lbC "github.com/getlantern/lantern-box/constant" lbO "github.com/getlantern/lantern-box/option" - "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" O "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common/json" @@ -41,7 +41,7 @@ const ( AutoSelectTag = "auto" ManualSelectTag = "manual" - urlTestInterval = 3 * time.Minute // must be less than urlTestIdleTimeout + urlTestInterval = 3 * time.Minute urlTestIdleTimeout = 15 * time.Minute cacheID = "lantern" @@ -75,14 +75,12 @@ type BoxOptions struct { InitialServer string `json:"initial_server,omitempty"` // BanditURLOverrides maps outbound tags to per-proxy callback URLs for // the bandit Thompson sampling system. When set, these override the - // default MutableURLTest URL for each specific outbound, allowing the - // server to detect which proxies successfully connected. - BanditURLOverrides map[string]string `json:"bandit_url_overrides,omitempty"` - BanditThroughputURL string `json:"bandit_throughput_url,omitempty"` - // URLTestSeed seeds the tunnel's URL test history storage at startup so - // prior latency results survive across tunnel close/open. Keyed by - // outbound/endpoint tag. - URLTestSeed map[string]adapter.URLTestHistory `json:"-"` + // default probe URL for each specific outbound, allowing the server to + // detect which proxies successfully connected. + BanditURLOverrides map[string]string `json:"bandit_url_overrides,omitempty"` + // SelectionHistorySeed seeds the tunnel's AutoSelectHistoryStorage + // at startup with the latest persisted snapshot per tag. + SelectionHistorySeed map[string]lbA.TagHistory `json:"tag_history"` } // baseOpts returns the minimum sing-box options required for the tunnel to @@ -459,14 +457,13 @@ func normalizeAdBlockRules(rules lcommon.AdBlockRules) lcommon.AdBlockRules { func urlTestOutbound(tag string, outbounds []string, urlOverrides map[string]string) O.Outbound { return O.Outbound{ - Type: lbC.TypeMutableURLTest, + Type: lbC.TypeMutableAutoSelect, Tag: tag, - Options: &lbO.MutableURLTestOutboundOptions{ - Outbounds: outbounds, - URL: "https://google.com/generate_204", - URLOverrides: urlOverrides, - Interval: badoption.Duration(urlTestInterval), - IdleTimeout: badoption.Duration(urlTestIdleTimeout), + Options: &lbO.MutableAutoSelectOutboundOptions{ + Outbounds: outbounds, + URL: "https://google.com/generate_204", + URLOverrides: urlOverrides, + BackgroundIntervalSeconds: uint32(urlTestInterval / time.Second), }, } } diff --git a/vpn/boxoptions_test.go b/vpn/boxoptions_test.go index 6b4ef59c..a674c0c9 100644 --- a/vpn/boxoptions_test.go +++ b/vpn/boxoptions_test.go @@ -165,9 +165,9 @@ func TestBuildOptions_BanditURLOverrides(t *testing.T) { out := findOutbound(opts.Outbounds, AutoSelectTag) require.NotNil(t, out, "missing auto-select outbound") - require.IsType(t, &lbO.MutableURLTestOutboundOptions{}, out.Options, "auto-select outbound options should be MutableURLTestOutboundOptions") - mutOpts := out.Options.(*lbO.MutableURLTestOutboundOptions) - assert.Equal(t, overrides, mutOpts.URLOverrides, "URLOverrides should be wired from config") + require.IsType(t, &lbO.MutableAutoSelectOutboundOptions{}, out.Options, "auto outbound options should be MutableAutoSelectOutboundOptions") + autoOpts := out.Options.(*lbO.MutableAutoSelectOutboundOptions) + assert.Equal(t, overrides, autoOpts.URLOverrides, "URLOverrides should be wired from config") } func contains[S ~[]E, E any](t *testing.T, s S, e E) bool { diff --git a/vpn/clash.go b/vpn/clash.go index cad2b54d..28b27da8 100644 --- a/vpn/clash.go +++ b/vpn/clash.go @@ -61,16 +61,16 @@ func newClashServer(ctx context.Context, _ log.ObservableFactory, options option runCtx, cancel := context.WithCancel(ctx) trafficManager := trafficontrol.NewManager() return &clashServer{ - ctx: runCtx, - cancel: cancel, - dnsRouter: service.FromContext[adapter.DNSRouter](ctx), - outbound: service.FromContext[adapter.OutboundManager](ctx), - endpoint: service.FromContext[adapter.EndpointManager](ctx), - urlTestHistory: service.FromContext[adapter.URLTestHistoryStorage](ctx), + ctx: runCtx, + cancel: cancel, + dnsRouter: service.FromContext[adapter.DNSRouter](ctx), + outbound: service.FromContext[adapter.OutboundManager](ctx), + endpoint: service.FromContext[adapter.EndpointManager](ctx), + urlTestHistory: service.FromContext[adapter.URLTestHistoryStorage](ctx), trafficManager: trafficManager, throughputTracker: newThroughputTracker(trafficManager, time.Second), - modeList: modeList, - mode: initial, + modeList: modeList, + mode: initial, }, nil } diff --git a/vpn/session_history.go b/vpn/session_history.go index 1f835a4f..f67a7b37 100644 --- a/vpn/session_history.go +++ b/vpn/session_history.go @@ -70,13 +70,13 @@ type SessionHistory struct { livePolledUp int64 livePolledDown int64 - mu sync.Mutex - current *Session - stored []Session - pollCancel context.CancelFunc - pollDone chan struct{} - pruneCancel context.CancelFunc - pruneDone chan struct{} + mu sync.Mutex + current *Session + stored []Session + pollCancel context.CancelFunc + pollDone chan struct{} + pruneCancel context.CancelFunc + pruneDone chan struct{} } // NewSessionHistory creates a SessionHistory subscribed to VPN status events. Call Close to diff --git a/vpn/tunnel.go b/vpn/tunnel.go index 5fa27686..c4b5d026 100644 --- a/vpn/tunnel.go +++ b/vpn/tunnel.go @@ -15,7 +15,6 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/conntrack" - "github.com/sagernet/sing-box/common/urltest" "github.com/sagernet/sing-box/experimental" "github.com/sagernet/sing-box/experimental/libbox" sblog "github.com/sagernet/sing-box/log" @@ -36,18 +35,19 @@ import ( "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/kindling" rlog "github.com/getlantern/radiance/log" "github.com/getlantern/radiance/servers" ) type tunnel struct { - ctx context.Context - lbService *libbox.BoxService - clashServer *clashServer - urltestHistory adapter.URLTestHistoryStorage - urlTestSeed map[string]adapter.URLTestHistory - logFactory sblog.ObservableFactory + ctx context.Context + lbService *libbox.BoxService + clashServer *clashServer + selectionHistory lbA.AutoSelectHistoryStorage + selectionHistorySeed map[string]lbA.TagHistory + logFactory sblog.ObservableFactory dataPath string @@ -143,12 +143,13 @@ func (t *tunnel) init(ctx context.Context, options string, platformIfce libbox.P experimental.RegisterClashServerConstructor(newClashServer) - t.urltestHistory = urltest.NewHistoryStorage() - for tag, h := range t.urlTestSeed { - t.urltestHistory.StoreURLTestHistory(tag, &h) + t.selectionHistory = lbA.NewAutoSelectHistoryStorage() + for tag, h := range t.selectionHistorySeed { + entry := h + t.selectionHistory.Store(tag, &entry) } - service.MustRegister[adapter.URLTestHistoryStorage](t.ctx, t.urltestHistory) - t.closers = append(t.closers, t.urltestHistory) + service.MustRegister[lbA.AutoSelectHistoryStorage](t.ctx, t.selectionHistory) + t.closers = append(t.closers, t.selectionHistory) slog.Log(nil, rlog.LevelTrace, "Creating libbox service") var lb *libbox.BoxService @@ -271,10 +272,45 @@ func (t *tunnel) connect(ctx context.Context) (err error) { closerFunc(func() error { mutGrpMgr.Close(); return nil }), }, t.closers...) + t.subscribeExhaustionSignal() + slog.Info("Tunnel connection established") return nil } +// subscribeExhaustionSignal bridges the auto group's exhaustion +// channel onto ExhaustionEvent so subscribers can react without the +// tunnel knowing about them. +func (t *tunnel) subscribeExhaustionSignal() { + g, ok := t.mutGrpMgr.OutboundGroup(AutoSelectTag) + if !ok { + slog.Warn("auto group not found; exhaustion events disabled", + "tag", AutoSelectTag) + return + } + signaler, ok := g.(lbA.ExhaustionSignaler) + if !ok { + slog.Warn("auto group does not implement ExhaustionSignaler; exhaustion events disabled", + "tag", AutoSelectTag) + return + } + go t.emitExhaustionEvents(signaler.ExhaustionSignal()) +} + +func (t *tunnel) emitExhaustionEvents(ch <-chan struct{}) { + for { + select { + case <-t.ctx.Done(): + return + case _, ok := <-ch: + if !ok { + return + } + events.Emit(ExhaustionEvent{}) + } + } +} + func (t *tunnel) selectMode(mode string) error { if t.lbService == nil { return fmt.Errorf("tunnel not running") @@ -439,20 +475,20 @@ func (t *tunnel) addOutbounds(list servers.ServerList) (err error) { } if len(list.URLOverrides) > 0 { - slog.Info("Applying bandit URL overrides to URL test group", + slog.Info("Applying bandit URL overrides to auto-select group", "override_count", len(list.URLOverrides), ) } if err := t.mutGrpMgr.SetURLOverrides(AutoSelectTag, list.URLOverrides); err != nil { slog.Warn("Failed to set URL overrides", "error", err) } else if len(list.URLOverrides) > 0 { - // Trigger an immediate URL test cycle when we have bandit overrides so + // Trigger an immediate probe cycle when we have bandit overrides so // callback probes are hit within seconds of config receipt rather than // waiting for the next scheduled interval (3 min). if err := t.mutGrpMgr.CheckOutbounds(AutoSelectTag); err != nil { - slog.Warn("Failed to trigger immediate URL test after bandit overrides", "error", err) + slog.Warn("Failed to trigger immediate probe cycle after bandit overrides", "error", err) } else { - slog.Info("Triggered immediate URL test for bandit callbacks") + slog.Info("Triggered immediate probe cycle for bandit callbacks") } } @@ -469,12 +505,11 @@ func (t *tunnel) removeOutbounds(tags []string) error { for _, tag := range tags { if out, loaded := mutGrpMgr.OutboundGroup(tag); loaded { if _, isMutGroup := out.(lbA.MutableOutboundGroup); isMutGroup { - continue // skip nested urltests + continue // skip nested groups } } err := mutGrpMgr.RemoveFromGroup(ManualSelectTag, tag) if err == nil { - // remove from urltest err = mutGrpMgr.RemoveFromGroup(AutoSelectTag, tag) } if errors.Is(err, groups.ErrIsClosed) { @@ -509,10 +544,10 @@ func (t *tunnel) updateOutbounds(list servers.ServerList) error { slog.Log(nil, rlog.LevelTrace, "Updating servers") selector, selectorExists := t.mutGrpMgr.OutboundGroup(ManualSelectTag) - _, urltestExists := t.mutGrpMgr.OutboundGroup(AutoSelectTag) - if !selectorExists || !urltestExists { - slog.Error("Selector or URL test group not found when updating outbounds") - return errors.New("selector or url test group not found") + _, autoSelectExists := t.mutGrpMgr.OutboundGroup(AutoSelectTag) + if !selectorExists || !autoSelectExists { + slog.Error("Selector or auto-select group not found when updating outbounds") + return errors.New("selector or auto-select group not found") } if contextDone(t.ctx) { diff --git a/vpn/types.go b/vpn/types.go index 18e37391..e57bdf11 100644 --- a/vpn/types.go +++ b/vpn/types.go @@ -4,11 +4,14 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" + lbA "github.com/getlantern/lantern-box/adapter" + "github.com/getlantern/radiance/events" ) -// URLTestHistoryStorage is an alias for the sing-box adapter interface. -type URLTestHistoryStorage = adapter.URLTestHistoryStorage +// AutoSelectHistoryStorage is an alias for the lantern-box adapter +// interface used by MutableAutoSelect for per-tag history persistence. +type AutoSelectHistoryStorage = lbA.AutoSelectHistoryStorage // StatusUpdateEvent is emitted when the VPN status changes. type StatusUpdateEvent struct { @@ -17,6 +20,12 @@ type StatusUpdateEvent struct { Error string `json:"error,omitempty"` } +// ExhaustionEvent is emitted when the MutableAutoSelect group's reconnection loop has exhausted +// all outbounds with no working candidate. +type ExhaustionEvent struct { + events.Event +} + // Selector is helper interface to check if an outbound is a selector or wrapper of selector. type Selector interface { adapter.OutboundGroup diff --git a/vpn/vpn.go b/vpn/vpn.go index 357d9b3b..14f9b114 100644 --- a/vpn/vpn.go +++ b/vpn/vpn.go @@ -16,7 +16,6 @@ import ( sbox "github.com/sagernet/sing-box" "github.com/sagernet/sing-box/adapter" - "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental/libbox" "github.com/sagernet/sing-box/option" @@ -145,7 +144,7 @@ func (c *VPNClient) Connect(boxOptions BoxOptions) error { if err != nil { return traces.RecordError(ctx, fmt.Errorf("failed to marshal options: %w", err)) } - return traces.RecordError(ctx, c.start(ctx, boxOptions.BasePath, string(opts), false, boxOptions.URLTestSeed)) + return traces.RecordError(ctx, c.start(ctx, boxOptions, string(opts), false)) } // Disconnect closes the tunnel and all active connections. @@ -161,10 +160,13 @@ func (c *VPNClient) Disconnect() error { return traces.RecordError(ctx, c.close()) } -func (c *VPNClient) start(ctx context.Context, path, options string, isRestart bool, urlTestSeed map[string]adapter.URLTestHistory) error { +func (c *VPNClient) start(ctx context.Context, boxOptions BoxOptions, options string, isRestart bool) error { c.logger.Debug("Starting tunnel", "options", options) c.setStatus(Connecting, nil) - t := tunnel{dataPath: path, urlTestSeed: urlTestSeed} + t := tunnel{ + dataPath: boxOptions.BasePath, + selectionHistorySeed: boxOptions.SelectionHistorySeed, + } if err := t.start(ctx, options, c.platformIfce, isRestart); err != nil { c.setStatus(ErrorStatus, err) return err @@ -236,7 +238,7 @@ func (c *VPNClient) Restart(boxOptions BoxOptions) error { c.setStatus(ErrorStatus, err) return traces.RecordError(ctx, fmt.Errorf("failed to marshal options: %w", err)) } - if err := c.start(ctx, boxOptions.BasePath, string(opts), true, boxOptions.URLTestSeed); err != nil { + if err := c.start(ctx, boxOptions, string(opts), true); err != nil { c.logger.Error("starting tunnel", "error", err) // c.start already set ErrorStatus; the guard lets Restarting→ErrorStatus through. return traces.RecordError(ctx, fmt.Errorf("starting tunnel: %w", err)) @@ -276,14 +278,15 @@ func (c *VPNClient) setStatus(s VPNStatus, err error) { events.Emit(evt) } -// HistoryStorage returns the tunnel's URL test history storage or nil if the tunnel is not connected. -func (c *VPNClient) HistoryStorage() adapter.URLTestHistoryStorage { +// HistoryStorage returns the tunnel's auto-select history storage, or +// nil if the tunnel is not connected. +func (c *VPNClient) HistoryStorage() AutoSelectHistoryStorage { c.mu.RLock() defer c.mu.RUnlock() if c.tunnel == nil { return nil } - return c.tunnel.urltestHistory + return c.tunnel.selectionHistory } // SelectServer changes the currently selected server to the one specified by tag. If tag is @@ -555,9 +558,6 @@ func (c *VPNClient) RunOfflineURLTests(basePath string, outbounds []option.Outbo // create offlineed box instance. we just use the standard box since we don't need a // platform interface for testing. ctx = service.ContextWith[filemanager.Manager](ctx, nil) - urlTestHistoryStorage := urltest.NewHistoryStorage() - ctx = service.ContextWithPtr(ctx, urlTestHistoryStorage) - service.MustRegister[adapter.URLTestHistoryStorage](ctx, urlTestHistoryStorage) // for good measure ctx, cancel = context.WithTimeout(ctx, 5*time.Second) // enough time for tests to complete or fail defer cancel() @@ -579,9 +579,14 @@ func (c *VPNClient) RunOfflineURLTests(basePath string, outbounds []option.Outbo if err := instance.PreStart(); err != nil { return nil, fmt.Errorf("failed to start sing-box instance: %w", err) } - outbound, _ := instance.Outbound().Outbound("offline-test") - tester, _ := outbound.(adapter.URLTestGroup) - // run URL tests + outbound, found := instance.Outbound().Outbound("offline-test") + if !found { + return nil, errors.New("offline-test outbound not registered") + } + tester, ok := outbound.(adapter.URLTestGroup) + if !ok { + return nil, fmt.Errorf("offline-test outbound (type %q) does not implement URLTestGroup", outbound.Type()) + } results, err := tester.URLTest(ctx) if err != nil { c.logger.Error("offline URL test failed", "error", err) From d374bfb4c3c982f1ee92db905a46bb5f48e003fe Mon Sep 17 00:00:00 2001 From: garmr-ulfr Date: Fri, 22 May 2026 16:19:36 -0700 Subject: [PATCH 2/4] servers, vpn: address PR feedback on autoselect integration Server.Clone deep-copies SelectionHistory (including UserFailures) so AllServers and GetServerByTag callers can't alias the Manager's shared state. The clashServer's unused urlTestHistory field is removed, and HistoryStorage now returns nil to satisfy adapter.ClashServer. Co-Authored-By: Claude Opus 4.7 (1M context) --- servers/manager.go | 23 +++++++++++++++++++---- vpn/clash.go | 8 +++----- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/servers/manager.go b/servers/manager.go index 0e8118c4..12a33d85 100644 --- a/servers/manager.go +++ b/servers/manager.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync" @@ -146,6 +147,22 @@ func (s *Server) UnmarshalJSON(data []byte) error { return nil } +func (s *Server) Clone() *Server { + cp := *s + if s.Credentials != nil { + c := *s.Credentials + cp.Credentials = &c + } + if cp.SelectionHistory != nil { + h := *cp.SelectionHistory + if len(h.UserFailures) > 0 { + h.UserFailures = slices.Clone(h.UserFailures) + } + cp.SelectionHistory = &h + } + return &cp +} + // ServerList is a batch of servers with optional URL overrides for bulk operations. type ServerList struct { Servers []*Server `json:"servers"` @@ -247,8 +264,7 @@ func (m *Manager) AllServers() []*Server { warnIfReaderStarved("AllServers", wait) result := make([]*Server, 0, len(m.servers)) for _, srv := range m.servers { - cp := *srv - result = append(result, &cp) + result = append(result, srv.Clone()) } return result } @@ -284,8 +300,7 @@ func (m *Manager) GetServerByTag(tag string) (*Server, bool) { if !exists { return nil, false } - cp := *s - return &cp, true + return s.Clone(), true } // warnIfReaderStarved logs a WARN with a goroutine stack dump when a reader diff --git a/vpn/clash.go b/vpn/clash.go index 28b27da8..22e3b6b6 100644 --- a/vpn/clash.go +++ b/vpn/clash.go @@ -35,7 +35,6 @@ type clashServer struct { outbound adapter.OutboundManager endpoint adapter.EndpointManager - urlTestHistory adapter.URLTestHistoryStorage trafficManager *trafficontrol.Manager throughputTracker *throughputTracker trackerDone chan struct{} @@ -66,7 +65,6 @@ func newClashServer(ctx context.Context, _ log.ObservableFactory, options option dnsRouter: service.FromContext[adapter.DNSRouter](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), endpoint: service.FromContext[adapter.EndpointManager](ctx), - urlTestHistory: service.FromContext[adapter.URLTestHistoryStorage](ctx), trafficManager: trafficManager, throughputTracker: newThroughputTracker(trafficManager, time.Second), modeList: modeList, @@ -123,10 +121,10 @@ func (s *clashServer) Close() error { return nil } +// HistoryStorage always returns nil. [adapter.URLTestHistoryStorage] is not used by [clashServer] +// so this is to satisfy [adapter.ClashServer]. func (s *clashServer) HistoryStorage() adapter.URLTestHistoryStorage { - s.mu.RLock() - defer s.mu.RUnlock() - return s.urlTestHistory + return nil } func (s *clashServer) TrafficManager() *trafficontrol.Manager { From bd5edd22dbc7e8c64b4bbc7b0e151c5c715ce094 Mon Sep 17 00:00:00 2001 From: garmr-ulfr Date: Tue, 26 May 2026 18:09:04 -0700 Subject: [PATCH 3/4] deps: bump lantern-box to autoselect switch-tolerance/ladder-cooldown Picks up the raised 200ms switch tolerance and 60s ladder cooldown that reduce active-member churn in uncensored networks. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 33042288..510fa5d3 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03 - github.com/getlantern/lantern-box v0.0.83-0.20260522034210-c49bbbfbd35f + github.com/getlantern/lantern-box v0.0.83-0.20260527005603-fde10e5a52fb github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb diff --git a/go.sum b/go.sum index 376a8a78..400c2f9a 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03 h1:dUTN7mnTTBcSvsURNs1rTlyKrD1uXUEPqxEZDfl+hb4= github.com/getlantern/kindling v0.0.0-20260516120759-a9712f95df03/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.83-0.20260522034210-c49bbbfbd35f h1:KzF+b1MXN/pOKKQ7j1NU4H56JWAHYopE9ztS+XCWpN8= -github.com/getlantern/lantern-box v0.0.83-0.20260522034210-c49bbbfbd35f/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= +github.com/getlantern/lantern-box v0.0.83-0.20260527005603-fde10e5a52fb h1:qBeCCwX4JzQT6hzuLs6TwCfCvoyMCdjT8w4Qpiw3Yzo= +github.com/getlantern/lantern-box v0.0.83-0.20260527005603-fde10e5a52fb/go.mod h1:wJhPQKdnwD6qW/ghAfzsrj/IfHZbvFSAfr52+Tu6dbw= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90 h1:P9JX1yAu2uq3b5YiT0sLtHkTrkZuttV8gPZh81nUuag= github.com/getlantern/lantern-water v0.0.0-20260317143726-e0ee64a11d90/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag= From 6d58bc9e115475cef632cc814a58031866219735 Mon Sep 17 00:00:00 2001 From: garmr-ulfr Date: Tue, 2 Jun 2026 12:01:22 -0700 Subject: [PATCH 4/4] bump lantern-box --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 835226be..736b9ab9 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/getlantern/domainfront v0.0.0-20260419161617-0bff0b2169f4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab - github.com/getlantern/lantern-box v0.0.87 + github.com/getlantern/lantern-box v0.0.88 github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b github.com/getlantern/semconv v0.0.0-20260327040646-21845dda05cb diff --git a/go.sum b/go.sum index ef732def..9662f714 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694 h1:iLWm6S/4 github.com/getlantern/keepcurrent v0.0.0-20260422161259-54a4d9a93694/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab h1:PitYhTvo3oHRKYl4pVAoOIN8bhM+Bw+JBWncMglvHSg= github.com/getlantern/kindling v0.0.0-20260529141244-21f8b144afab/go.mod h1:TGTxpoNVwc8Be4qkBNtf5oj2psJaEIZEq47GOPS7zkA= -github.com/getlantern/lantern-box v0.0.87 h1:GSI9t7K5LNBA3XxKZAHLqEK/8KNJ8Ua7+5ssYEpjvdk= -github.com/getlantern/lantern-box v0.0.87/go.mod h1:7/V3MIU6+5zt4Ybg06rRJI0Yi/WSfqUfp8wWnk5ofDs= +github.com/getlantern/lantern-box v0.0.88 h1:A+K417P/+1IMaI0NyEsyJLDY7xlk/faKpAgRI+WXyB0= +github.com/getlantern/lantern-box v0.0.88/go.mod h1:7/V3MIU6+5zt4Ybg06rRJI0Yi/WSfqUfp8wWnk5ofDs= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 h1:grfGavAUp2E9w9ZoJuM3FyWyQ0sCJ64V4ZMKtZKRqTc= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395/go.mod h1:3JpJgwi4KEI6rS9loOAvcBp+F2jP65d0tTg2GQcTPBU= github.com/getlantern/ops v0.0.0-20231025133620-f368ab734534 h1:3BwvWj0JZzFEvNNiMhCu4bf60nqcIuQpTYb00Ezm1ag=