This is an automated email from the ASF dual-hosted git repository.

Alanxtl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git


The following commit(s) were added to refs/heads/develop by this push:
     new e506f0d4a fix(config_center/nacos): remove unused context.WithCancel 
and call CancelListenConfig on last listener removal (#3441)
e506f0d4a is described below

commit e506f0d4a9401d346404ac697ff8eb4b53ac9f8e
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 11 13:56:58 2026 +0800

    fix(config_center/nacos): remove unused context.WithCancel and call 
CancelListenConfig on last listener removal (#3441)
    
    * fix(config_center/nacos): remove unused context.WithCancel and call 
CancelListenConfig on last listener removal
    
    The addListener() function created context.WithCancel but the cancel
    function was never called, causing a goroutine and context leak.
    Additionally, removeListener() never called CancelListenConfig to
    unsubscribe from the nacos server, leaving stale config change
    listeners active indefinitely.
    
    Changes:
    - Remove unused context.WithCancel calls in addListener()
    - Replace context.CancelFunc value type with struct{} in keyListeners
      map (the cancel function was never used)
    - In removeListener(), call CancelListenConfig when the last listener
      for a key is removed (matching Apollo and File implementations)
    - Clean up keyListeners entry when no listeners remain
    - Add nil client guard for test compatibility
    
    Co-Authored-By: Claude <[email protected]>
    
    * fix(config_center/nacos): use keyListenerSet with mutex for thread-safe 
listener management
    
    Address Copilot review feedback:
    1. Race condition: Replace bare sync.Map with keyListenerSet struct that
       uses sync.Mutex to make add/remove/check-empty atomic, preventing
       concurrent addListener from racing with removeListener's emptiness
       check and CancelListenConfig call.
    2. DataId/Group consistency: Store the resolved group string in
       keyListenerSet so that CancelListenConfig uses the exact same group
       that was passed to ListenConfig, avoiding mismatch from re-derivation.
    3. Delete keyListeners entry before CancelListenConfig to prevent new
       addListener from finding a stale set after cancellation.
    4. Use snapshot() for callback iteration to safely iterate while
       mutations may occur.
    
    Also add test for multi-listener removal behavior.
    
    * fix(config_center/nacos): guard listener lifecycle race
    
    * style(config_center/nacos): apply imports-formatter to concurrency test
    
    Resolve the `make check-fmt` failure flagged in PR review: the new
    listener_concurrency_test.go did not follow the repo's import grouping
    order. Re-run `make fmt`; `check-fmt` now passes.
    
    Co-Authored-By: Claude <[email protected]>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 config_center/nacos/impl.go                      |   3 +-
 config_center/nacos/listener.go                  | 111 ++++++--
 config_center/nacos/listener_concurrency_test.go | 320 +++++++++++++++++++++++
 config_center/nacos/listener_test.go             |  42 ++-
 4 files changed, 447 insertions(+), 29 deletions(-)

diff --git a/config_center/nacos/impl.go b/config_center/nacos/impl.go
index db672d796..003237ecb 100644
--- a/config_center/nacos/impl.go
+++ b/config_center/nacos/impl.go
@@ -57,7 +57,8 @@ type nacosDynamicConfiguration struct {
        cltLock      sync.Mutex
        done         chan struct{}
        client       *nacosClient.NacosConfigClient
-       keyListeners sync.Map // 
sync.Map[listenKey]*sync.Map[config_center.ConfigurationListener]context.CancelFunc
+       keyListeners sync.Map // sync.Map[listenKey]*keyListenerSet
+       listenerLock sync.Mutex
        parser       parser.ConfigurationParser
 }
 
diff --git a/config_center/nacos/listener.go b/config_center/nacos/listener.go
index 542da3a17..c1b44ff05 100644
--- a/config_center/nacos/listener.go
+++ b/config_center/nacos/listener.go
@@ -18,7 +18,6 @@
 package nacos
 
 import (
-       "context"
        "sync"
 )
 
@@ -37,29 +36,81 @@ import (
        "dubbo.apache.org/dubbo-go/v3/remoting"
 )
 
-func callback(listenersMap *sync.Map, _, group, dataId, data string) {
-       listenersMap.Range(func(key, value any) bool {
-               
key.(config_center.ConfigurationListener).Process(&config_center.ConfigChangeEvent{Key:
 dataId, Value: data, ConfigType: remoting.EventTypeUpdate})
+// keyListenerSet holds the listeners for a single config key.
+// The mutex protects the listener map while callbacks take snapshots and
+// add/remove paths mutate it.
+type keyListenerSet struct {
+       mu        sync.Mutex
+       listeners map[config_center.ConfigurationListener]struct{}
+       group     string // resolved group used to register with nacos, stored 
for consistent cancel
+}
+
+func newKeyListenerSet(group string) *keyListenerSet {
+       return &keyListenerSet{
+               listeners: 
make(map[config_center.ConfigurationListener]struct{}),
+               group:     group,
+       }
+}
+
+func (s *keyListenerSet) add(listener config_center.ConfigurationListener) {
+       s.mu.Lock()
+       s.listeners[listener] = struct{}{}
+       s.mu.Unlock()
+}
+
+// remove removes a listener and reports whether the set is now empty.
+// Callers that use the empty result to cancel the Nacos subscription must hold
+// nacosDynamicConfiguration.listenerLock.
+func (s *keyListenerSet) remove(listener config_center.ConfigurationListener) 
bool {
+       s.mu.Lock()
+       delete(s.listeners, listener)
+       empty := len(s.listeners) == 0
+       s.mu.Unlock()
+       return empty
+}
+
+// snapshot returns a snapshot of the current listeners for safe iteration.
+func (s *keyListenerSet) snapshot() []config_center.ConfigurationListener {
+       s.mu.Lock()
+       snapshot := make([]config_center.ConfigurationListener, 0, 
len(s.listeners))
+       for l := range s.listeners {
+               snapshot = append(snapshot, l)
+       }
+       s.mu.Unlock()
+       return snapshot
+}
+
+func callback(set *keyListenerSet, _, group, dataId, data string) {
+       for _, l := range set.snapshot() {
+               l.Process(&config_center.ConfigChangeEvent{Key: dataId, Value: 
data, ConfigType: remoting.EventTypeUpdate})
                metrics.Publish(metricsConfigCenter.NewIncMetricEvent(dataId, 
group, remoting.EventTypeUpdate, metricsConfigCenter.Nacos))
-               return true
-       })
+       }
 }
 
 func (n *nacosDynamicConfiguration) addListener(key string, listener 
config_center.ConfigurationListener) {
-       rawListenersMap, loaded := n.keyListeners.Load(key)
+       group := n.resolvedGroup(n.url.GetParam(constant.NacosGroupKey, 
constant2.DEFAULT_GROUP))
+
+       // The listener lifecycle lock serializes add/remove bookkeeping with 
the
+       // corresponding ListenConfig/CancelListenConfig calls. This prevents a
+       // concurrent addListener from slipping in between removeListener's 
emptiness
+       // check and its CancelListenConfig call.
+       n.listenerLock.Lock()
+       defer n.listenerLock.Unlock()
+
+       rawSet, loaded := n.keyListeners.Load(key)
        if !loaded {
-               _, cancel := context.WithCancel(context.Background())
-               listenersMap := &sync.Map{}
-               listenersMap.Store(listener, cancel)
+               set := newKeyListenerSet(group)
+               set.add(listener)
 
                // double load for invalid race
-               rawListenersMap, loaded = n.keyListeners.LoadOrStore(key, 
listenersMap)
+               var actual any
+               actual, loaded = n.keyListeners.LoadOrStore(key, set)
                if !loaded {
                        err := n.client.Client().ListenConfig(vo.ConfigParam{
                                DataId: key,
-                               Group:  
n.resolvedGroup(n.url.GetParam(constant.NacosGroupKey, 
constant2.DEFAULT_GROUP)),
+                               Group:  group,
                                OnChange: func(namespace, group, dataId, data 
string) {
-                                       go callback(listenersMap, namespace, 
group, dataId, data)
+                                       go callback(set, namespace, group, 
dataId, data)
                                },
                        })
                        if err != nil {
@@ -69,18 +120,38 @@ func (n *nacosDynamicConfiguration) addListener(key 
string, listener config_cent
                        }
                        return
                }
+               rawSet = actual
        }
-       _, cancel := context.WithCancel(context.Background())
-       listenersMap := rawListenersMap.(*sync.Map)
-       listenersMap.Store(listener, cancel)
+       rawSet.(*keyListenerSet).add(listener)
 }
 
 func (n *nacosDynamicConfiguration) removeListener(key string, listener 
config_center.ConfigurationListener) {
-       rawListenersMap, loaded := n.keyListeners.Load(key)
+       n.listenerLock.Lock()
+       defer n.listenerLock.Unlock()
+
+       rawSet, loaded := n.keyListeners.Load(key)
        if !loaded {
                logger.Errorf("[ConfigCenter][Nacos] key is not be listened, 
key=%s", key)
-       } else {
-               listenersMap := rawListenersMap.(*sync.Map)
-               listenersMap.Delete(listener)
+               return
+       }
+       set := rawSet.(*keyListenerSet)
+       isEmpty := set.remove(listener)
+
+       if isEmpty {
+               // Delete from keyListeners and cancel the nacos subscription 
as a single
+               // atomic step. Because addListener takes the same lock, no 
concurrent add
+               // can register a fresh subscription between this Delete and the
+               // CancelListenConfig below — the race that previously let a 
late cancel
+               // drop a just-registered listener.
+               n.keyListeners.Delete(key)
+               if n.client != nil {
+                       err := 
n.client.Client().CancelListenConfig(vo.ConfigParam{
+                               DataId: key,
+                               Group:  set.group,
+                       })
+                       if err != nil {
+                               logger.Errorf("[ConfigCenter][Nacos] cancel 
listen config fail, key=%s, err=%v", key, err)
+                       }
+               }
        }
 }
diff --git a/config_center/nacos/listener_concurrency_test.go 
b/config_center/nacos/listener_concurrency_test.go
new file mode 100644
index 000000000..3b8b030fb
--- /dev/null
+++ b/config_center/nacos/listener_concurrency_test.go
@@ -0,0 +1,320 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nacos
+
+import (
+       "strconv"
+       "sync"
+       "sync/atomic"
+       "testing"
+       "time"
+)
+
+import (
+       nacosClient "github.com/dubbogo/gost/database/kv/nacos"
+
+       "github.com/nacos-group/nacos-sdk-go/v2/model"
+       "github.com/nacos-group/nacos-sdk-go/v2/vo"
+
+       "github.com/stretchr/testify/assert"
+)
+
+import (
+       "dubbo.apache.org/dubbo-go/v3/common"
+       "dubbo.apache.org/dubbo-go/v3/common/constant"
+       "dubbo.apache.org/dubbo-go/v3/config_center"
+)
+
+// noopListener is a no-op ConfigurationListener used by the concurrency tests.
+// It carries an id so each instance has a distinct address — an empty struct
+// would have Go collapse every &noopListener{} to a single address
+// (runtime.zerobase), making every listener the same map key and invalidating
+// the add/remove bookkeeping under test.
+type noopListener struct {
+       id int
+}
+
+var listenerSeq int32
+
+func newNoopListener() *noopListener {
+       return &noopListener{id: int(atomic.AddInt32(&listenerSeq, 1))}
+}
+
+func (r *noopListener) Process(*config_center.ConfigChangeEvent) {}
+
+// countingConfigClient is a concurrency-safe IConfigClient that mirrors the
+// real Nacos SDK semantics relevant to the TOCTOU race under review: both
+// ListenConfig and CancelListenConfig operate on a single per-(dataId, group)
+// slot. ListenConfig sets the slot; CancelListenConfig clears it
+// unconditionally — exactly like the SDK's cacheMap.Remove, which drops
+// whatever subscription currently occupies the slot regardless of when it was
+// registered. This is what makes the race dangerous: a late cancel can wipe a
+// subscription a racing listen just established.
+//
+// The optional onListenDone / onCancelEnter hooks let a deterministic test
+// choreograph the exact interleaving the reviewer described (a cancel landing
+// after a racing listen) without relying on timing.
+type countingConfigClient struct {
+       mu            sync.Mutex
+       slot          map[string]struct{} // (dataId|group) -> present when a 
subscription is live
+       listens       int32
+       cancels       int32
+       onListenDone  func() // invoked after ListenConfig sets the slot
+       onCancelEnter func() // invoked before CancelListenConfig clears the 
slot
+}
+
+func newCountingConfigClient() *countingConfigClient {
+       return &countingConfigClient{slot: make(map[string]struct{})}
+}
+
+func (c *countingConfigClient) keyOf(p vo.ConfigParam) string { return 
p.DataId + "|" + p.Group }
+
+func (c *countingConfigClient) ListenConfig(p vo.ConfigParam) error {
+       c.mu.Lock()
+       c.slot[c.keyOf(p)] = struct{}{}
+       c.mu.Unlock()
+       atomic.AddInt32(&c.listens, 1)
+       if c.onListenDone != nil {
+               c.onListenDone()
+       }
+       return nil
+}
+
+func (c *countingConfigClient) CancelListenConfig(p vo.ConfigParam) error {
+       if c.onCancelEnter != nil {
+               c.onCancelEnter()
+       }
+       c.mu.Lock()
+       delete(c.slot, c.keyOf(p))
+       c.mu.Unlock()
+       atomic.AddInt32(&c.cancels, 1)
+       return nil
+}
+
+// hasSubscription reports whether a live subscription occupies the slot.
+func (c *countingConfigClient) hasSubscription(key, group string) bool {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+       _, ok := c.slot[key+"|"+group]
+       return ok
+}
+
+func (c *countingConfigClient) GetConfig(vo.ConfigParam) (string, error)   { 
return "", nil }
+func (c *countingConfigClient) PublishConfig(vo.ConfigParam) (bool, error) { 
return true, nil }
+func (c *countingConfigClient) DeleteConfig(vo.ConfigParam) (bool, error)  { 
return true, nil }
+func (c *countingConfigClient) SearchConfig(vo.SearchConfigParam) 
(*model.ConfigPage, error) {
+       return nil, nil
+}
+func (c *countingConfigClient) CloseClient() {}
+
+// newTestConfig builds a nacosDynamicConfiguration wired to a counting client.
+func newTestConfig(t *testing.T, client *countingConfigClient) 
*nacosDynamicConfiguration {
+       t.Helper()
+       nc := &nacosClient.NacosConfigClient{}
+       nc.SetClient(client)
+       u, err := common.NewURL("registry://127.0.0.1:8848",
+               common.WithParamsValue(constant.NacosGroupKey, "test-group"))
+       assert.NoError(t, err)
+       return &nacosDynamicConfiguration{
+               url:    u,
+               client: nc,
+       }
+}
+
+// TestAddRemoveListenerConcurrentChurnKeepsPersistentSubscription verifies the
+// non-empty listener path under concurrent churn. A persistent listener 
remains
+// registered while many temporary listeners are added and removed, so the key
+// should never be canceled and the persistent subscription must remain live.
+func TestAddRemoveListenerConcurrentChurnKeepsPersistentSubscription(t 
*testing.T) {
+       const key = "race-key"
+       client := newCountingConfigClient()
+       n := newTestConfig(t, client)
+
+       // A persistent listener that stays registered for the whole test; its
+       // subscription must never be dropped by a racing remove's cancel.
+       persistent := newNoopListener()
+       n.addListener(key, persistent)
+       assert.True(t, client.hasSubscription(key, "test-group"),
+               "initial add should establish a subscription")
+
+       var wg sync.WaitGroup
+       workers := 32
+       start := make(chan struct{})
+       for w := 0; w < workers; w++ {
+               wg.Add(1)
+               go func() {
+                       defer wg.Done()
+                       <-start
+                       for i := 0; i < 200; i++ {
+                               l := newNoopListener()
+                               n.addListener(key, l)
+                               n.removeListener(key, l)
+                       }
+               }()
+       }
+       close(start)
+       wg.Wait()
+
+       // The persistent listener is still registered, so a live subscription 
must
+       // remain. Under the unfixed code a racing cancel could have cleared 
the slot
+       // after a worker's add repopulated it, leaving the slot empty while the
+       // persistent listener is still registered — the assertion below catches
+       // that. (Each worker balances its own add/remove, so no worker listener
+       // should remain; only the persistent one.)
+       assert.True(t, client.hasSubscription(key, "test-group"),
+               "persistent listener's subscription must survive concurrent 
churn")
+       assert.Equal(t, int32(0), atomic.LoadInt32(&client.cancels),
+               "persistent listener keeps the key non-empty, so this test 
should not exercise cancel")
+
+       // Sanity: the persistent listener is still the only one tracked for 
the key.
+       rawSet, ok := n.keyListeners.Load(key)
+       assert.True(t, ok, "key entry must remain while a listener is 
registered")
+       set := rawSet.(*keyListenerSet)
+       assert.Len(t, set.snapshot(), 1, "only the persistent listener should 
remain")
+}
+
+// TestRemoveThenAddNoLostSubscriptionDeterministic forces the exact 
interleaving
+// the reviewer described, with no reliance on timing:
+//
+//  1. remover: removeListener observes the last listener leaving, deletes the
+//     keyListeners entry, and enters CancelListenConfig — where we park it.
+//  2. adder: while the remover is parked inside CancelListenConfig, 
addListener
+//     runs: it sees no entry, stores a fresh set, and calls ListenConfig 
(which
+//     populates the slot).
+//  3. remover: we release CancelListenConfig; it clears the slot 
unconditionally
+//     (mirroring the SDK's cacheMap.Remove).
+//
+// Under the unfixed code the adder's slot is wiped by the late cancel, 
leaving a
+// registered listener with no live Nacos subscription. Under the fix the
+// listener lifecycle lock makes step 2 wait until the remover has fully 
finished
+// (including its CancelListenConfig), so the adder's ListenConfig wins and the
+// slot survives.
+func TestRemoveThenAddNoLostSubscriptionDeterministic(t *testing.T) {
+       const key = "det-key"
+
+       // cancelEntered is closed once the remover is parked inside 
CancelListenConfig;
+       // cancelRelease unblocks it to finish clearing the slot.
+       cancelEntered := make(chan struct{})
+       cancelRelease := make(chan struct{})
+
+       client := newCountingConfigClient()
+       client.onCancelEnter = func() {
+               close(cancelEntered)
+               <-cancelRelease
+       }
+       n := newTestConfig(t, client)
+
+       // One registered listener so removeListener has a real last-listener 
to remove.
+       first := newNoopListener()
+       n.addListener(key, first)
+       assert.True(t, client.hasSubscription(key, "test-group"))
+
+       removerDone := make(chan struct{})
+       go func() {
+               defer close(removerDone)
+               n.removeListener(key, first)
+       }()
+
+       // Wait until the remover is parked inside CancelListenConfig (i.e. it 
has
+       // already deleted the keyListeners entry). This is the dangerous 
window.
+       <-cancelEntered
+
+       // Now the adder races in: under the fix it blocks on the listener 
lifecycle
+       // lock held by the remover, so ListenConfig has NOT run yet. Under the
+       // unfixed code the adder proceeds, stores a fresh set, and ListenConfig
+       // populates the slot while the remover is still parked.
+       var adderWG sync.WaitGroup
+       adderWG.Add(1)
+       go func() {
+               defer adderWG.Done()
+               l := newNoopListener()
+               n.addListener(key, l)
+       }()
+
+       // Probe whether the adder could complete before we release the cancel. 
With
+       // the lifecycle lock the adder is still blocked; without it the adder
+       // finished and populated the slot. Either way we then release the 
cancel and
+       // wait for both goroutines to finish.
+       time.Sleep(200 * time.Millisecond)
+       close(cancelRelease)
+
+       <-removerDone
+       adderWG.Wait()
+
+       // The adder registered a listener, so a live subscription must remain. 
On the
+       // unfixed code the late cancel wiped the adder's slot, leaving it 
empty —
+       // this assertion fails there and passes with the fix.
+       assert.True(t, client.hasSubscription(key, "test-group"),
+               "adder's subscription must survive the racing remover's cancel")
+       rawSet, ok := n.keyListeners.Load(key)
+       assert.True(t, ok, "key entry must remain while the adder's listener is 
registered")
+       assert.Len(t, rawSet.(*keyListenerSet).snapshot(), 1, "only the adder's 
listener should remain")
+}
+
+// TestListenerConcurrentStress runs many goroutines adding and removing
+// listeners across several keys. Run with -race to catch data races. The
+// invariant checked at the end: for every key, a live Nacos subscription
+// exists iff at least one listener remains registered. Under the unfixed code
+// the TOCTOU window can leave a key with registered listeners but no slot
+// (or, less likely, an orphaned slot after all listeners are gone).
+func TestListenerConcurrentStress(t *testing.T) {
+       client := newCountingConfigClient()
+       n := newTestConfig(t, client)
+
+       const numKeys = 8
+       const goroutines = 64
+       const iterations = 200
+
+       // remaining[k] is the number of listeners we intentionally left 
registered
+       // for key k (those we did not remove).
+       var remaining [numKeys]int32
+
+       keyFor := func(k int) string { return "stress-key-" + strconv.Itoa(k) }
+
+       var wg sync.WaitGroup
+       for g := 0; g < goroutines; g++ {
+               wg.Add(1)
+               go func(g int) {
+                       defer wg.Done()
+                       for i := 0; i < iterations; i++ {
+                               k := g % numKeys
+                               key := keyFor(k)
+                               l := newNoopListener()
+                               n.addListener(key, l)
+                               // Keep roughly half of the listeners so some 
keys retain
+                               // subscriptions and others churn to empty 
(exercising the
+                               // cancel path) and back.
+                               if (i+g)%2 == 0 {
+                                       n.removeListener(key, l)
+                               } else {
+                                       atomic.AddInt32(&remaining[k], 1)
+                               }
+                       }
+               }(g)
+       }
+       wg.Wait()
+
+       // A live subscription must exist iff at least one listener remains.
+       for k := 0; k < numKeys; k++ {
+               key := keyFor(k)
+               got := client.hasSubscription(key, "test-group")
+               want := atomic.LoadInt32(&remaining[k]) > 0
+               assert.Equal(t, want, got,
+                       "key %s: subscription live=%v but remaining 
listeners=%d", key, got, remaining[k])
+       }
+}
diff --git a/config_center/nacos/listener_test.go 
b/config_center/nacos/listener_test.go
index 00ceb7f1d..121fe5d32 100644
--- a/config_center/nacos/listener_test.go
+++ b/config_center/nacos/listener_test.go
@@ -18,7 +18,6 @@
 package nacos
 
 import (
-       "sync"
        "testing"
 )
 
@@ -37,10 +36,10 @@ func (r *recordingListener) Process(e 
*config_center.ConfigChangeEvent) {
 
 func TestCallback(t *testing.T) {
        l := &recordingListener{}
-       var m sync.Map
-       m.Store(l, struct{}{})
+       set := newKeyListenerSet("test-group")
+       set.add(l)
 
-       callback(&m, "", "g", "data", "payload")
+       callback(set, "", "g", "data", "payload")
 
        if len(l.events) != 1 {
                t.Fatalf("expected 1 event, got %d", len(l.events))
@@ -54,13 +53,40 @@ func TestRemoveListener(t *testing.T) {
        n := &nacosDynamicConfiguration{}
        key := "k"
        l := &recordingListener{}
-       inner := &sync.Map{}
-       inner.Store(l, struct{}{})
-       n.keyListeners.Store(key, inner)
+       set := newKeyListenerSet("test-group")
+       set.add(l)
+       n.keyListeners.Store(key, set)
 
        n.removeListener(key, l)
 
-       if _, ok := inner.Load(l); ok {
+       if _, ok := set.listeners[l]; ok {
                t.Fatalf("listener should be removed")
        }
+       // After removing the only listener, the key should be deleted from 
keyListeners
+       if _, loaded := n.keyListeners.Load(key); loaded {
+               t.Fatalf("key should be deleted from keyListeners after last 
listener is removed")
+       }
+}
+
+func TestRemoveListenerMultipleListeners(t *testing.T) {
+       n := &nacosDynamicConfiguration{}
+       key := "k"
+       l1 := &recordingListener{}
+       l2 := &recordingListener{}
+       set := newKeyListenerSet("test-group")
+       set.add(l1)
+       set.add(l2)
+       n.keyListeners.Store(key, set)
+
+       // Remove first listener — key should still exist
+       n.removeListener(key, l1)
+       if _, loaded := n.keyListeners.Load(key); !loaded {
+               t.Fatalf("key should still exist after removing one of multiple 
listeners")
+       }
+
+       // Remove second listener — key should be deleted
+       n.removeListener(key, l2)
+       if _, loaded := n.keyListeners.Load(key); loaded {
+               t.Fatalf("key should be deleted from keyListeners after last 
listener is removed")
+       }
 }

Reply via email to