Four maps had no bound on a box with 512 MB that runs for months. The health board only ever inserted — the delete exists but no path in this fork calls it — and it lives on the engine context, so it outlives every generation. Its keys are node tags, and providers rename nodes on each subscription refresh: about 440k keys a year, some 88 MB. Alert dedup keyed on MAC with no delete at all. The stats aggregator's server and outbound counters were the only ones with no cap, no prune and no top-N, and one of them was handed to the panel whole on every poll. They are bounded now, evicting least-recently-seen, with numbers argued from this box rather than round: the board holds 4096 against a live generation of about 1200 tags, so a rename day cannot evict a tag still in use. Nothing is dropped silently — the same rule the log sink already follows — and a new Dropped section in the snapshot reports all six bounded aggregates, including the three that had been evicting without saying so. Snapshot did O(devices × domains) under the aggregator lock, sorting five thousand entries to show fifteen, and could read the DHCP lease file from inside it. Meanwhile the event subscribers have 64-slot buffers that drop without a counter, so an open Overview page cost the query log real rows. Selection is top-K now — proven byte-identical to the old sort over 200 random trials — and both the lease read and the row ordering happen outside the lock. The panel server had one timeout, on headers. An unauthenticated client could hold a goroutine, a socket and a descriptor forever by sending its body one byte at a time; a stopped reader on the log stream held the handler, the pipe and a child process that outlived the request. Every phase is bounded now, with the unauthenticated route on a tighter budget than the rest, and the log stream renewing its deadline per chunk so a slow-but-reading client is never truncated. And the last of the detour transports: each call built a fresh one, and the alert delivery path dropped it, pinning keep-alive sessions through the engine's own outbounds for 90 seconds — eighteen times the budget a retiring generation gets. The race skip is gone from the gate. The test it existed for raced in its own clock, not in the product; that is fixed, so nothing is excluded under -race any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
3.9 KiB
Go
156 lines
3.9 KiB
Go
package urltest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
C "github.com/sagernet/sing-box/constant"
|
|
M "github.com/sagernet/sing/common/metadata"
|
|
N "github.com/sagernet/sing/common/network"
|
|
"github.com/sagernet/sing/common/ntp"
|
|
"github.com/sagernet/sing/common/observable"
|
|
)
|
|
|
|
type HistoryStorage struct {
|
|
access sync.RWMutex
|
|
delayHistory map[string]*adapter.URLTestHistory
|
|
updateHooks []*observable.Subscriber[struct{}]
|
|
// evicted counts entries dropped by the capacity bound (board_lx.go). The map
|
|
// is keyed by outbound tags chosen by a subscription provider, so it needs a
|
|
// ceiling; see the comment on maxBoardEntries.
|
|
evicted uint64
|
|
}
|
|
|
|
func NewHistoryStorage() *HistoryStorage {
|
|
return &HistoryStorage{
|
|
delayHistory: make(map[string]*adapter.URLTestHistory),
|
|
}
|
|
}
|
|
|
|
func (s *HistoryStorage) AddUpdateHook(hook *observable.Subscriber[struct{}]) {
|
|
s.access.Lock()
|
|
defer s.access.Unlock()
|
|
s.updateHooks = append(s.updateHooks, hook)
|
|
}
|
|
|
|
func (s *HistoryStorage) NotifyUpdated() {
|
|
s.access.RLock()
|
|
defer s.access.RUnlock()
|
|
s.notifyUpdated()
|
|
}
|
|
|
|
func (s *HistoryStorage) LoadURLTestHistory(tag string) *adapter.URLTestHistory {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.access.RLock()
|
|
defer s.access.RUnlock()
|
|
return s.delayHistory[tag]
|
|
}
|
|
|
|
func (s *HistoryStorage) DeleteURLTestHistory(tag string) {
|
|
s.access.Lock()
|
|
delete(s.delayHistory, tag)
|
|
s.notifyUpdated()
|
|
s.access.Unlock()
|
|
}
|
|
|
|
func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTestHistory) {
|
|
s.access.Lock()
|
|
// lx:begin health-board
|
|
// Health board (plan §5.A): overwriting an entry with a fresh success must not
|
|
// erase the recorded failure — Verdict compares LastOK against LastFail, so
|
|
// dropping LastFail here would forge an eternal "alive". Callers only ever set
|
|
// LastOK/Delay on success; a caller that deliberately sets LastFail wins.
|
|
if history.LastFail.IsZero() {
|
|
if previous := s.delayHistory[tag]; previous != nil {
|
|
history.LastFail = previous.LastFail
|
|
}
|
|
}
|
|
// lx:end health-board
|
|
s.delayHistory[tag] = history
|
|
// lx:begin health-board — the map is keyed by provider-chosen tags and the
|
|
// store outlives every engine generation, so it must bound itself here: no
|
|
// shater path ever calls DeleteURLTestHistory. See maxBoardEntries.
|
|
s.pruneLocked()
|
|
// lx:end health-board
|
|
s.notifyUpdated()
|
|
s.access.Unlock()
|
|
}
|
|
|
|
func (s *HistoryStorage) notifyUpdated() {
|
|
for _, updateHook := range s.updateHooks {
|
|
updateHook.Emit(struct{}{})
|
|
}
|
|
}
|
|
|
|
func (s *HistoryStorage) Close() error {
|
|
s.access.Lock()
|
|
defer s.access.Unlock()
|
|
s.updateHooks = nil
|
|
return nil
|
|
}
|
|
|
|
func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) {
|
|
if link == "" {
|
|
link = "https://www.gstatic.com/generate_204"
|
|
}
|
|
linkURL, err := url.Parse(link)
|
|
if err != nil {
|
|
return
|
|
}
|
|
hostname := linkURL.Hostname()
|
|
port := linkURL.Port()
|
|
if port == "" {
|
|
switch linkURL.Scheme {
|
|
case "http":
|
|
port = "80"
|
|
case "https":
|
|
port = "443"
|
|
}
|
|
}
|
|
|
|
start := time.Now()
|
|
instance, err := detour.DialContext(ctx, "tcp", M.ParseSocksaddrHostPortStr(hostname, port))
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer instance.Close()
|
|
if N.NeedHandshakeForWrite(instance) {
|
|
start = time.Now()
|
|
}
|
|
req, err := http.NewRequest(http.MethodHead, link, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
client := http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return instance, nil
|
|
},
|
|
TLSClientConfig: &tls.Config{
|
|
Time: ntp.TimeFuncFromContext(ctx),
|
|
RootCAs: adapter.RootPoolFromContext(ctx),
|
|
},
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
Timeout: C.TCPTimeout,
|
|
}
|
|
defer client.CloseIdleConnections()
|
|
resp, err := client.Do(req.WithContext(ctx))
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
t = uint16(time.Since(start) / time.Millisecond)
|
|
return
|
|
}
|