fix(node): drop online clients and sub-nodes of nodes no longer synced

What the master derives from a node's reports (online clients, active
inbounds, learned sub-nodes) must live only while that node is still
synced; ClearNodeOnlineClients states it: a downed node must not keep its
clients listed as online.

Only a failed snapshot fetch cleared the online set, and only a failed
probe cleared sub-nodes. A disabled node (both jobs skip it), a node marked
offline before the sync tick reached it, a deleted node, and a node whose
snapshot fetched but failed to merge all kept their clients online in
onlineClients, onlineByGuid and activeInbounds, which the dashboard and a
parent master's /clients/onlines read. Disabled and deleted nodes also kept
their sub-nodes on the Nodes page until the panel restarted.

The traffic sync now keeps online sets only for enabled, online nodes in
its list, the heartbeat keeps sub-nodes only for enabled listed nodes, both
before the empty-list return, and a failed merge clears like a failed
fetch. The sync job's one-line call has no job-level test: that package
cannot install the xray process, so RetainSyncedNodeOnlineClients carries
the tested rule.
This commit is contained in:
Sanaei
2026-09-15 19:27:38 +02:00
parent ea66aa4971
commit a84bbeab2e
7 changed files with 220 additions and 0 deletions
+18
View File
@@ -355,6 +355,10 @@ func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnaps
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed)
return inner
})
if err != nil {
// As on a failed fetch: a node whose snapshot did not merge keeps no online set.
s.ClearNodeOnlineClients(nodeID)
}
return structuralChange, err
}
@@ -1454,6 +1458,20 @@ func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
}
}
// RetainSyncedNodeOnlineClients keeps online clients only for nodes the traffic
// sync still fetches; a node missing from nodes was deleted.
func (s *InboundService) RetainSyncedNodeOnlineClients(nodes []*model.Node) {
process := currentXrayProcess()
if process == nil {
return
}
synced := make(map[int]bool, len(nodes))
for _, n := range nodes {
synced[n.Id] = n.Enable && n.Status == "online"
}
process.RetainNodeOnlineClients(func(nodeID int) bool { return synced[nodeID] })
}
// panelGuid returns this panel's stable self-identifier, used to key the local
// panel's own clients in the per-node online maps (#4983).
func (s *InboundService) panelGuid() string {
+16
View File
@@ -86,6 +86,22 @@ func (s *NodeService) ClearDescendants(nodeID int) {
nodeDescendantsMu.Unlock()
}
// RetainEnabledNodeDescendants drops sub-nodes learned from nodes the heartbeat no
// longer probes: disabled ones it skips, deleted ones missing from nodes.
func (s *NodeService) RetainEnabledNodeDescendants(nodes []*model.Node) {
enabled := make(map[int]bool, len(nodes))
for _, n := range nodes {
enabled[n.Id] = n.Enable
}
nodeDescendantsMu.Lock()
for nodeID := range nodeDescendantsCache {
if !enabled[nodeID] {
delete(nodeDescendantsCache, nodeID)
}
}
nodeDescendantsMu.Unlock()
}
func cachedDescendants() []model.NodeSummary {
nodeDescendantsMu.RLock()
defer nodeDescendantsMu.RUnlock()
@@ -0,0 +1,69 @@
package service
import (
"fmt"
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func useOnlineTestProcess(t *testing.T) *xray.Process {
t.Helper()
previousProcess, previousResult := xrayState.snapshot()
process := xray.NewTestProcess(nil, "")
xrayState.replace(process)
t.Cleanup(func() {
xrayState.mu.Lock()
xrayState.process = previousProcess
xrayState.result = previousResult
xrayState.mu.Unlock()
})
return process
}
// Only a failed snapshot fetch used to clear a node's online set, so a node the
// sync stopped reaching (disabled, marked offline, deleted) kept its clients online.
func TestRetainSyncedNodeOnlineClientsDropsUnsyncedNodes(t *testing.T) {
setupConflictDB(t)
process := useOnlineTestProcess(t)
svc := InboundService{}
for id := 1; id <= 4; id++ {
guid := fmt.Sprintf("g%d", id)
svc.SetNodeOnlineTree(id, map[string][]string{guid: {guid + "@x"}})
process.SetNodeActiveInboundTree(id, map[string][]string{guid: {"in-" + guid}})
}
svc.RetainSyncedNodeOnlineClients([]*model.Node{
{Id: 1, Enable: true, Status: "online"},
{Id: 2, Enable: false, Status: "online"},
{Id: 3, Enable: true, Status: "offline"},
})
if got, want := svc.GetOnlineClientsByGuid(), map[string][]string{"g1": {"g1@x"}}; !reflect.DeepEqual(got, want) {
t.Errorf("online by guid = %v, want %v", got, want)
}
if got, want := svc.GetActiveInboundsByGuid(), map[string][]string{"g1": {"in-g1"}}; !reflect.DeepEqual(got, want) {
t.Errorf("active inbounds by guid = %v, want %v", got, want)
}
}
func TestSetRemoteTrafficFailureClearsNodeOnlineClients(t *testing.T) {
setupConflictDB(t)
useOnlineTestProcess(t)
svc := InboundService{}
svc.SetNodeOnlineTree(7, map[string][]string{"g7": {"a@x"}})
if err := database.GetDB().Exec("DROP TABLE inbounds").Error; err != nil {
t.Fatalf("drop inbounds: %v", err)
}
if _, err := svc.SetRemoteTraffic(7, &runtime.TrafficSnapshot{}, false, false); err == nil {
t.Fatal("SetRemoteTraffic succeeded without an inbounds table")
}
if got := svc.GetOnlineClientsByGuid(); len(got) != 0 {
t.Errorf("online by guid after a failed merge = %v, want none", got)
}
}