Fix group status updates broken by API service

The URL test history update hook and the Clash mode update hook were
single-slot: the API service's attached service overwrote the hook set
by the daemon, so clients stopped receiving group updates. Replace both
with multicast hook lists.

Also share a single URL test history storage via context: Clash API
looked it up under a key nobody registered and fell back to its own
empty storage, so dashboards showed no delay once an API service was
configured. Selector changes now notify through the shared storage,
covering selections made from any API surface.
This commit is contained in:
世界 2026-06-13 09:30:11 +08:00
parent 266f590b92
commit 22650b3020
No known key found for this signature in database
GPG Key ID: CD109927C34A63C4
9 changed files with 49 additions and 52 deletions

View File

@ -16,8 +16,7 @@ type ClashServer interface {
Mode() string
ModeList() []string
SetMode(mode string)
SetModeUpdateHook(hook *observable.Subscriber[struct{}])
HistoryStorage() URLTestHistoryStorage
AddModeUpdateHook(hook *observable.Subscriber[struct{}])
}
type URLTestHistory struct {
@ -25,14 +24,6 @@ type URLTestHistory struct {
Delay uint16 `json:"delay"`
}
type URLTestHistoryStorage interface {
SetHook(hook *observable.Subscriber[struct{}])
LoadURLTestHistory(tag string) *URLTestHistory
DeleteURLTestHistory(tag string)
StoreURLTestHistory(tag string, history *URLTestHistory)
Close() error
}
type V2RayServer interface {
LifecycleService
StatsService() ConnectionTracker

2
box.go
View File

@ -159,7 +159,7 @@ func New(options Options) (*Box, error) {
needAPIService := common.Any(options.Services, func(it option.Service) bool {
return it.Type == C.TypeAPI
})
if needAPIService && service.PtrFromContext[urltest.HistoryStorage](ctx) == nil {
if service.PtrFromContext[urltest.HistoryStorage](ctx) == nil {
ctx = service.ContextWithPtr(ctx, urltest.NewHistoryStorage())
}
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)

View File

@ -17,12 +17,10 @@ import (
"github.com/sagernet/sing/common/observable"
)
var _ adapter.URLTestHistoryStorage = (*HistoryStorage)(nil)
type HistoryStorage struct {
access sync.RWMutex
delayHistory map[string]*adapter.URLTestHistory
updateHook *observable.Subscriber[struct{}]
updateHooks []*observable.Subscriber[struct{}]
}
func NewHistoryStorage() *HistoryStorage {
@ -31,8 +29,16 @@ func NewHistoryStorage() *HistoryStorage {
}
}
func (s *HistoryStorage) SetHook(hook *observable.Subscriber[struct{}]) {
s.updateHook = hook
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 {
@ -59,8 +65,7 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTes
}
func (s *HistoryStorage) notifyUpdated() {
updateHook := s.updateHook
if updateHook != nil {
for _, updateHook := range s.updateHooks {
updateHook.Emit(struct{}{})
}
}
@ -68,7 +73,7 @@ func (s *HistoryStorage) notifyUpdated() {
func (s *HistoryStorage) Close() error {
s.access.Lock()
defer s.access.Unlock()
s.updateHook = nil
s.updateHooks = nil
return nil
}

View File

@ -19,9 +19,9 @@ func NewAttachedService(ctx context.Context) *StartedService {
s.instance = instance
s.serviceStatus = &ServiceStatus{Status: ServiceStatus_STARTED}
s.startedAt = time.Now()
instance.urlTestHistoryStorage.SetHook(s.urlTestSubscriber)
instance.urlTestHistoryStorage.AddUpdateHook(s.urlTestSubscriber)
if instance.clashServer != nil {
instance.clashServer.SetModeUpdateHook(s.clashModeSubscriber)
instance.clashServer.AddModeUpdateHook(s.clashModeSubscriber)
}
instance.logFactory.(log.ObservableFactory).AttachPlatformWriter(s)
return s

View File

@ -28,7 +28,7 @@ type Instance struct {
trafficManager *trafficcontrol.Manager
cacheFile adapter.CacheFile
pauseManager pause.Manager
urlTestHistoryStorage adapter.URLTestHistoryStorage
urlTestHistoryStorage *urltest.HistoryStorage
outboundManager adapter.OutboundManager
endpointManager adapter.EndpointManager
logFactory log.Factory

View File

@ -62,7 +62,6 @@ type StartedService struct {
startedAt time.Time
urlTestSubscriber *observable.Subscriber[struct{}]
urlTestObserver *observable.Observer[struct{}]
urlTestHistoryStorage *urltest.HistoryStorage
clashModeSubscriber *observable.Subscriber[struct{}]
clashModeObserver *observable.Observer[struct{}]
}
@ -102,7 +101,6 @@ func NewStartedService(options ServiceOptions) *StartedService {
serviceStatusSubscriber: observable.NewSubscriber[*ServiceStatus](4),
logSubscriber: observable.NewSubscriber[*log.Entry](128),
urlTestSubscriber: observable.NewSubscriber[struct{}](1),
urlTestHistoryStorage: urltest.NewHistoryStorage(),
clashModeSubscriber: observable.NewSubscriber[struct{}](1),
}
s.serviceStatusObserver = observable.NewObserver(s.serviceStatusSubscriber, 2)
@ -202,9 +200,9 @@ func (s *StartedService) StartOrReloadService(profileContent string, options *Ov
return s.updateStatusError(err)
}
s.instance = instance
instance.urlTestHistoryStorage.SetHook(s.urlTestSubscriber)
instance.urlTestHistoryStorage.AddUpdateHook(s.urlTestSubscriber)
if instance.clashServer != nil {
instance.clashServer.SetModeUpdateHook(s.clashModeSubscriber)
instance.clashServer.AddModeUpdateHook(s.clashModeSubscriber)
}
s.serviceAccess.Unlock()
err = instance.Start()
@ -649,7 +647,6 @@ func (s *StartedService) SelectOutbound(ctx context.Context, request *SelectOutb
if !selector.SelectOutbound(request.OutboundTag) {
return nil, status.Error(codes.NotFound, "outbound not found in selector: "+request.OutboundTag)
}
s.urlTestObserver.Emit(struct{}{})
return &emptypb.Empty{}, nil
}

View File

@ -9,6 +9,7 @@ import (
"os"
"runtime"
"strings"
"sync"
"syscall"
"time"
@ -49,12 +50,13 @@ type Server struct {
logger log.Logger
httpServer *http.Server
trafficManager *trafficcontrol.Manager
urlTestHistory adapter.URLTestHistoryStorage
urlTestHistory *urltest.HistoryStorage
logDebug bool
mode string
modeList []string
modeUpdateHook *observable.Subscriber[struct{}]
mode string
modeList []string
modeUpdateAccess sync.Mutex
modeUpdateHooks []*observable.Subscriber[struct{}]
externalController bool
externalUI string
@ -67,6 +69,10 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op
if trafficManager == nil {
return nil, E.New("missing traffic manager")
}
urlTestHistory := service.PtrFromContext[urltest.HistoryStorage](ctx)
if urlTestHistory == nil {
return nil, E.New("missing URL test history storage")
}
chiRouter := chi.NewRouter()
s := &Server{
ctx: ctx,
@ -81,16 +87,13 @@ func NewServer(ctx context.Context, logFactory log.ObservableFactory, options op
Handler: chiRouter,
},
trafficManager: trafficManager,
urlTestHistory: urlTestHistory,
logDebug: logFactory.Level() >= log.LevelDebug,
modeList: options.ModeList,
externalController: options.ExternalController != "",
externalUIDownloadURL: options.ExternalUIDownloadURL,
externalUIDownloadDetour: options.ExternalUIDownloadDetour,
}
s.urlTestHistory = service.FromContext[adapter.URLTestHistoryStorage](ctx)
if s.urlTestHistory == nil {
s.urlTestHistory = urltest.NewHistoryStorage()
}
defaultMode := "Rule"
if options.DefaultMode != "" {
defaultMode = options.DefaultMode
@ -195,7 +198,6 @@ func (s *Server) Start(stage adapter.StartStage) error {
func (s *Server) Close() error {
return common.Close(
common.PtrOrNil(s.httpServer),
s.urlTestHistory,
)
}
@ -207,8 +209,10 @@ func (s *Server) ModeList() []string {
return s.modeList
}
func (s *Server) SetModeUpdateHook(hook *observable.Subscriber[struct{}]) {
s.modeUpdateHook = hook
func (s *Server) AddModeUpdateHook(hook *observable.Subscriber[struct{}]) {
s.modeUpdateAccess.Lock()
defer s.modeUpdateAccess.Unlock()
s.modeUpdateHooks = append(s.modeUpdateHooks, hook)
}
func (s *Server) SetMode(newMode string) {
@ -224,9 +228,11 @@ func (s *Server) SetMode(newMode string) {
return
}
s.mode = newMode
if s.modeUpdateHook != nil {
s.modeUpdateHook.Emit(struct{}{})
s.modeUpdateAccess.Lock()
for _, hook := range s.modeUpdateHooks {
hook.Emit(struct{}{})
}
s.modeUpdateAccess.Unlock()
s.dnsRouter.ClearCache()
cacheFile := service.FromContext[adapter.CacheFile](s.ctx)
if cacheFile != nil {
@ -238,10 +244,6 @@ func (s *Server) SetMode(newMode string) {
s.logger.Info("updated mode: ", newMode)
}
func (s *Server) HistoryStorage() adapter.URLTestHistoryStorage {
return s.urlTestHistory
}
func authentication(serverSecret string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {

View File

@ -8,6 +8,7 @@ import (
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/common/interrupt"
"github.com/sagernet/sing-box/common/urltest"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
@ -40,6 +41,7 @@ type Selector struct {
defaultTag string
outbounds map[string]adapter.Outbound
selected common.TypedValue[adapter.Outbound]
history *urltest.HistoryStorage
interruptGroup *interrupt.Group
interruptExternalConnections bool
}
@ -54,6 +56,7 @@ func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextL
tags: options.Outbounds,
defaultTag: options.Default,
outbounds: make(map[string]adapter.Outbound),
history: service.PtrFromContext[urltest.HistoryStorage](ctx),
interruptGroup: interrupt.NewGroup(),
interruptExternalConnections: options.InterruptExistConnections,
}
@ -137,6 +140,9 @@ func (s *Selector) SelectOutbound(tag string) bool {
}
}
s.interruptGroup.Interrupt(s.interruptExternalConnections)
if s.history != nil {
s.history.NotifyUpdated()
}
return true
}

View File

@ -195,7 +195,7 @@ type URLTestGroup struct {
interval time.Duration
tolerance uint16
idleTimeout time.Duration
history adapter.URLTestHistoryStorage
history *urltest.HistoryStorage
checking atomic.Bool
selectedOutboundTCP adapter.Outbound
selectedOutboundUDP adapter.Outbound
@ -221,13 +221,9 @@ func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManage
if interval > idleTimeout {
return nil, E.New("interval must be less or equal than idle_timeout")
}
var history adapter.URLTestHistoryStorage
if historyFromCtx := service.PtrFromContext[urltest.HistoryStorage](ctx); historyFromCtx != nil {
history = historyFromCtx
} else if clashServer := service.FromContext[adapter.ClashServer](ctx); clashServer != nil {
history = clashServer.HistoryStorage()
} else {
history = urltest.NewHistoryStorage()
history := service.PtrFromContext[urltest.HistoryStorage](ctx)
if history == nil {
return nil, E.New("missing URL test history storage")
}
return &URLTestGroup{
ctx: ctx,