diff --git a/internal/web/job/node_traffic_sync_job.go b/internal/web/job/node_traffic_sync_job.go index 78cd2c234..c38368693 100644 --- a/internal/web/job/node_traffic_sync_job.go +++ b/internal/web/job/node_traffic_sync_job.go @@ -390,6 +390,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy return nil } snap.ManagedAliases = rt.AdoptedInboundAliases() + syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases) service.FilterNodeSnapshot(n, snap) _, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id) if !dirty { @@ -414,7 +415,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy if changed { j.structural.set() } - if !dirty && n.InboundsAdoptedAt == 0 { + if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt { if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil { logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr) } @@ -475,3 +476,12 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy } return active } + +// Whether this sync can perform the "first clean adoption" that +// InboundsAdoptedAt records (#6283). +func syncCanAdoptInbounds(n *model.Node, adoptedAliases []string) bool { + if n == nil || n.InboundSyncMode != "selected" { + return true + } + return len(n.InboundTags) > 0 || len(adoptedAliases) > 0 +} diff --git a/internal/web/job/node_traffic_sync_job_test.go b/internal/web/job/node_traffic_sync_job_test.go index ec04e3503..c65c2546c 100644 --- a/internal/web/job/node_traffic_sync_job_test.go +++ b/internal/web/job/node_traffic_sync_job_test.go @@ -3,6 +3,8 @@ package job import ( "sync" "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func TestAtomicBool_DefaultIsFalse(t *testing.T) { @@ -67,3 +69,44 @@ func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) { t.Fatalf("expected exactly one reader to observe true, got %d", trueCount) } } + +// Regression (#6283): a node onboarded in selected mode with an empty tag +// list empties its snapshot via FilterNodeSnapshot before the merge sees it, +// so that sync adopts nothing and must not stamp InboundsAdoptedAt. +func TestSyncCanAdoptInbounds(t *testing.T) { + cases := []struct { + name string + node *model.Node + aliases []string + expected bool + }{ + {"all mode always adopts", &model.Node{InboundSyncMode: "all"}, nil, true}, + { + "selected with tags adopts", + &model.Node{InboundSyncMode: "selected", InboundTags: []string{"in-443-tcp"}}, + nil, + true, + }, + { + "selected empty with adopted alias adopts", + &model.Node{InboundSyncMode: "selected"}, + []string{"in-443-tcp"}, + true, + }, + { + // The reported bug: registering in selected mode and choosing + // tags afterwards stamped adoption while adopting nothing. + "selected empty with no aliases adopts nothing", + &model.Node{InboundSyncMode: "selected"}, + nil, + false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := syncCanAdoptInbounds(c.node, c.aliases); got != c.expected { + t.Fatalf("syncCanAdoptInbounds(%+v, %v) = %v, want %v", c.node, c.aliases, got, c.expected) + } + }) + } +}