From a255ab7c65ebd72c7a5a3a611dfd819690b45ec4 Mon Sep 17 00:00:00 2001 From: yzxcj797 <54314860+yzxcj797@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:37:55 +0800 Subject: [PATCH] fix(node): don't stamp InboundsAdoptedAt when the sync adopted nothing (#6284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(node): don't stamp InboundsAdoptedAt when the sync adopted nothing Onboarding a node in selected mode with an empty tag list empties the traffic snapshot via FilterNodeSnapshot before the merge sees it, so the first clean sync adopts nothing — yet syncOne stamped InboundsAdoptedAt regardless. The flag is documented as the first clean sync that imported the node's pre-existing inbounds; stamping it in this state arms the reconcile sweep (gated on the flag since 200ea091, the fix for #5898) to delete the node's pre-existing inbounds on their next real sync: registering first and choosing tags afterwards destroyed the node's inbounds. Gate the stamp on the sync actually being able to adopt: in selected mode, at least one selected tag or adopted alias must exist for the snapshot filter to keep anything. Fixes #6283 * restore atomicBool tests; trim comment to repo 2-line cap The new test file unintentionally replaced the existing node_traffic_sync_job_test.go, dropping its four atomicBool tests; restore them and keep only an additive diff. Trim the syncCanAdopt doc comment to the repository's 2-line comment cap. * trim syncCanAdopt comment to the 2-line cap --- internal/web/job/node_traffic_sync_job.go | 12 +++++- .../web/job/node_traffic_sync_job_test.go | 43 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) 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) + } + }) + } +}