From 0298b2f33b45906aa56467e9a0cf3720b074695c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 13 Jun 2026 09:30:11 +0800 Subject: [PATCH] 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. --- adapter/experimental.go | 11 +--------- box.go | 2 +- common/urltest/urltest.go | 21 +++++++++++-------- daemon/attached_service.go | 4 ++-- daemon/instance.go | 2 +- daemon/started_service.go | 7 ++----- experimental/clashapi/server.go | 36 +++++++++++++++++---------------- protocol/group/selector.go | 6 ++++++ protocol/group/urltest.go | 12 ++++------- 9 files changed, 49 insertions(+), 52 deletions(-) diff --git a/adapter/experimental.go b/adapter/experimental.go index b4b9e663..b5eb3243 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -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 diff --git a/box.go b/box.go index 1b8baf13..7e7b6b7a 100644 --- a/box.go +++ b/box.go @@ -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) diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go index 29d790e4..11169c68 100644 --- a/common/urltest/urltest.go +++ b/common/urltest/urltest.go @@ -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 } diff --git a/daemon/attached_service.go b/daemon/attached_service.go index bf965d90..b726ac2c 100644 --- a/daemon/attached_service.go +++ b/daemon/attached_service.go @@ -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 diff --git a/daemon/instance.go b/daemon/instance.go index 42c5b66b..8ff58777 100644 --- a/daemon/instance.go +++ b/daemon/instance.go @@ -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 diff --git a/daemon/started_service.go b/daemon/started_service.go index 36298cd8..39da809e 100644 --- a/daemon/started_service.go +++ b/daemon/started_service.go @@ -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 } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 149912d5..c0487536 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -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) { diff --git a/protocol/group/selector.go b/protocol/group/selector.go index 85bea2b9..607387bd 100644 --- a/protocol/group/selector.go +++ b/protocol/group/selector.go @@ -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 } diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 51fe3d0a..bc13b637 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -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,