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

ShannonDing pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/rocketmq-client-go.git


The following commit(s) were added to refs/heads/master by this push:
     new 461524a  fix: Fix resource leaks and shutdown handling in shared 
clients (#1244)
461524a is described below

commit 461524ac40fd5e3b3f47735c9b73721374f43504
Author: wenxuwan <[email protected]>
AuthorDate: Sat Oct 10 15:03:58 2026 +0800

    fix: Fix resource leaks and shutdown handling in shared clients (#1244)
    
    * seperate interface and implement
    
    * fix panic when close tracedispatcher
    
    * Restore rlog/log.go
    
    * Delete default.go
    
    * fix: release client resources and unblock workers on shutdown
    
    * fix: isolate NameServer transports when rebuilding clients
    
    * fix: copy NameServer snapshots outside the client registry lock
    
    * fix: avoid retaining retired clients in transaction handlers
    
    ---------
    
    Co-authored-by: 文徐 <[email protected]>
---
 consumer/consumer.go                       |  41 ++++-
 consumer/pull_consumer.go                  |  14 ++
 consumer/push_consumer.go                  |  58 ++++--
 consumer/push_consumer_test.go             |  15 +-
 consumer/resource_lifecycle_test.go        |  59 ++++++
 consumer/shutdown_wait_test.go             | 160 ++++++++++++++++
 consumer/statistics.go                     |  13 +-
 internal/client.go                         |  87 +++++++--
 internal/client_lifecycle_test.go          | 282 ++++++++++++++++++++++++++++-
 internal/namesrv.go                        |  30 ++-
 internal/remote/future.go                  |  22 ++-
 internal/remote/remote_client.go           |  72 ++++++--
 internal/remote/remote_client_test.go      |   5 +-
 internal/remote/resource_lifecycle_test.go | 212 ++++++++++++++++++++++
 internal/remote/tcp_conn.go                |  48 ++++-
 internal/resource_lifecycle_test.go        | 141 +++++++++++++++
 internal/utils/wait.go                     |  43 +++++
 producer/producer.go                       |  50 +++--
 producer/resource_lifecycle_test.go        |  86 +++++++++
 19 files changed, 1351 insertions(+), 87 deletions(-)

diff --git a/consumer/consumer.go b/consumer/consumer.go
index 34f1cb5..c749d49 100644
--- a/consumer/consumer.go
+++ b/consumer/consumer.go
@@ -269,6 +269,8 @@ type defaultConsumer struct {
        storage               OffsetStore
        // chan for push consumer
        prCh chan PullRequest
+       // Shares the shutdown signal owned by the push or pull consumer.
+       done <-chan struct{}
 
        pullFromWhichNodeTable sync.Map
 
@@ -702,6 +704,11 @@ func (dc *defaultConsumer) 
buildProcessQueueTableByBrokerName() map[string][]*pr
 
 func (dc *defaultConsumer) updateProcessQueueTable(topic string, mqs 
[]*primitive.MessageQueue) bool {
        var changed bool
+       select {
+       case <-dc.done:
+               return false
+       default:
+       }
        mqSet := make(map[primitive.MessageQueue]bool)
        for idx := range mqs {
                mqSet[*mqs[idx]] = true
@@ -736,6 +743,11 @@ func (dc *defaultConsumer) updateProcessQueueTable(topic 
string, mqs []*primitiv
        })
 
        for item := range mqSet {
+               select {
+               case <-dc.done:
+                       return changed
+               default:
+               }
                // BUG: the mq will send to channel, if not copy once, the next 
iter will modify the mq in the channel.
                mq := item
                _, exist := dc.processQueueTable.Load(mq)
@@ -772,7 +784,13 @@ func (dc *defaultConsumer) updateProcessQueueTable(topic 
string, mqs []*primitiv
                                        pq:            pq,
                                        nextOffset:    nextOffset,
                                }
-                               dc.prCh <- pr
+                               if !dc.dispatchPullRequest(pr) {
+                                       // Shutdown may have finished scanning 
the table while offset lookup
+                                       // was still in flight. Dispose the 
newly published queue ourselves.
+                                       pq.WithDropped(true)
+                                       dc.processQueueTable.Delete(mq)
+                                       return changed
+                               }
                                changed = true
                        }
                } else {
@@ -786,6 +804,27 @@ func (dc *defaultConsumer) updateProcessQueueTable(topic 
string, mqs []*primitiv
        return changed
 }
 
+// dispatchPullRequest does not retain a rebalance worker after its receiver 
stops.
+func (dc *defaultConsumer) dispatchPullRequest(pr PullRequest) bool {
+       select {
+       case <-dc.done:
+               return false
+       default:
+       }
+       select {
+       case <-dc.done:
+               return false
+       case dc.prCh <- pr:
+       }
+       // A buffered send can win a select at the same time as shutdown.
+       select {
+       case <-dc.done:
+               return false
+       default:
+               return true
+       }
+}
+
 func (dc *defaultConsumer) removeUnnecessaryMessageQueue(mq 
*primitive.MessageQueue, pq *processQueue) bool {
        dc.storage.persist([]*primitive.MessageQueue{mq})
        dc.storage.remove(mq)
diff --git a/consumer/pull_consumer.go b/consumer/pull_consumer.go
index 8c38305..76cdc9e 100644
--- a/consumer/pull_consumer.go
+++ b/consumer/pull_consumer.go
@@ -136,6 +136,7 @@ func NewPullConsumer(options ...Option) 
(*defaultPullConsumer, error) {
                consumeRequestCache: make(chan *ConsumeRequest, 4),
                GroupName:           dc.option.GroupName,
        }
+       dc.done = c.done
        dc.mqChanged = c.messageQueueChanged
        c.submitToConsume = c.consumeMessageConcurrently
        c.interceptor = primitive.ChainInterceptors(c.option.Interceptors...)
@@ -305,13 +306,26 @@ func (pc *defaultPullConsumer) Start() error {
        return err
 }
 
+// Poll returns ErrService from the errors package when the consumer is shut 
down.
 func (pc *defaultPullConsumer) Poll(ctx context.Context, timeout 
time.Duration) (*ConsumeRequest, error) {
+       select {
+       case <-pc.done:
+               return nil, errors2.ErrService
+       default:
+       }
        ctx, cancel := context.WithTimeout(ctx, timeout)
        defer cancel()
        select {
        case <-ctx.Done():
                return nil, ErrNoNewMsg
+       case <-pc.done:
+               return nil, errors2.ErrService
        case cr := <-pc.consumeRequestCache:
+               select {
+               case <-pc.done:
+                       return nil, errors2.ErrService
+               default:
+               }
                if cr.processQueue.IsDroppd() {
                        rlog.Info("defaultPullConsumer poll the message queue 
not be able to consume, because it was dropped", map[string]interface{}{
                                rlog.LogKeyMessageQueue:  
cr.messageQueue.String(),
diff --git a/consumer/push_consumer.go b/consumer/push_consumer.go
index 2d640bc..9ca2f55 100644
--- a/consumer/push_consumer.go
+++ b/consumer/push_consumer.go
@@ -123,6 +123,7 @@ func NewPushConsumer(opts ...Option) (*pushConsumer, error) 
{
                done:            make(chan struct{}, 1),
                consumeFunc:     utils.NewSet(),
        }
+       dc.done = p.done
        dc.mqChanged = p.messageQueueChanged
        if p.consumeOrderly {
                p.submitToConsume = p.consumeMessageOrderly
@@ -203,7 +204,9 @@ func (pc *pushConsumer) Start() error {
                        if pc.consumeOrderly {
                                return
                        }
-                       time.Sleep(pc.option.ConsumeTimeout)
+                       if !utils.WaitFor(pc.done, pc.option.ConsumeTimeout) {
+                               return
+                       }
                        pc.cleanExpiredMsg()
 
                        ticker := time.NewTicker(pc.option.ConsumeTimeout)
@@ -227,7 +230,9 @@ func (pc *pushConsumer) Start() error {
                                return
                        }
 
-                       time.Sleep(1000 * time.Millisecond)
+                       if !utils.WaitFor(pc.done, 1000*time.Millisecond) {
+                               return
+                       }
                        pc.lockAll()
 
                        lockTicker := 
time.NewTicker(pc.option.RebalanceLockInterval)
@@ -709,7 +714,9 @@ func (pc *pushConsumer) pullMessage(request *PullRequest) {
                }
                if sleepTime > 0 {
                        rlog.Debug(fmt.Sprintf("pull MessageQueue: %d sleep %d 
ms for mq: %v", request.mq.QueueId, sleepTime/time.Millisecond, request.mq), 
nil)
-                       time.Sleep(sleepTime)
+                       if !utils.WaitFor(pc.done, sleepTime) {
+                               return
+                       }
                }
                // reset time
                sleepTime = pc.option.PullInterval.Load()
@@ -935,7 +942,9 @@ func (pc *pushConsumer) pullMessage(request *PullRequest) {
                        })
                        request.nextOffset = result.NextBeginOffset
                        pq.WithDropped(true)
-                       time.Sleep(10 * time.Second)
+                       if !utils.WaitFor(pc.done, 10*time.Second) {
+                               return
+                       }
                        pc.storage.update(request.mq, request.nextOffset, false)
                        
pc.storage.persist([]*primitive.MessageQueue{request.mq})
                        pc.processQueueTable.Delete(*request.mq)
@@ -1035,7 +1044,9 @@ func (pc *pushConsumer) ResetOffset(topic string, table 
map[primitive.MessageQue
                copyPc.Store(&mq, pq)
                return true
        })
-       time.Sleep(10 * time.Second)
+       if !utils.WaitFor(pc.done, 10*time.Second) {
+               return
+       }
        for _, mq := range mqs {
                if _, ok := table[*mq]; ok {
                        pc.storage.update(mq, table[*mq], false)
@@ -1144,7 +1155,13 @@ func (pc *pushConsumer) consumeMessageConcurrently(pq 
*processQueue, mq *primiti
                }
                ch, _ := pc.crCh.Load(mq.Topic)
                if channel, ok := ch.(chan struct{}); ok {
-                       channel <- struct{}{}
+                       select {
+                       case <-pc.done:
+                               return
+                       case <-pq.closeChan:
+                               return
+                       case channel <- struct{}{}:
+                       }
                }
                go primitive.WithRecover(func() {
                        defer func() {
@@ -1164,6 +1181,11 @@ func (pc *pushConsumer) consumeMessageConcurrently(pq 
*processQueue, mq *primiti
                                }
                        }()
                RETRY:
+                       select {
+                       case <-pc.done:
+                               return
+                       default:
+                       }
                        if pq.IsDroppd() {
                                rlog.Info("the message queue not be able to 
consume, because it was dropped", map[string]interface{}{
                                        rlog.LogKeyMessageQueue:  mq.String(),
@@ -1250,7 +1272,9 @@ func (pc *pushConsumer) consumeMessageConcurrently(pq 
*processQueue, mq *primiti
                                }
                                if len(msgBackFailed) > 0 {
                                        subMsgs = msgBackFailed
-                                       time.Sleep(5 * time.Second)
+                                       if !utils.WaitFor(pc.done, 
5*time.Second) {
+                                               return
+                                       }
                                        goto RETRY
                                }
                        } else {
@@ -1303,7 +1327,7 @@ func (pc *pushConsumer) consumeMessageOrderly(pq 
*processQueue, mq *primitive.Me
                        }
                        interval := time.Now().Sub(beginTime)
                        if interval > pc.option.MaxTimeConsumeContinuously {
-                               time.Sleep(10 * time.Millisecond)
+                               utils.WaitFor(pc.done, 10*time.Millisecond)
                                return
                        }
                        batchSize := pc.option.ConsumeMessageBatchMaxSize
@@ -1377,7 +1401,9 @@ func (pc *pushConsumer) consumeMessageOrderly(pq 
*processQueue, mq *primitive.Me
                                case SuspendCurrentQueueAMoment:
                                        if pc.checkReconsumeTimes(msgs) {
                                                
pq.makeMessageToCosumeAgain(msgs...)
-                                               
time.Sleep(time.Duration(orderlyCtx.SuspendCurrentQueueTimeMillis) * 
time.Millisecond)
+                                               if !utils.WaitFor(pc.done, 
time.Duration(orderlyCtx.SuspendCurrentQueueTimeMillis)*time.Millisecond) {
+                                                       return
+                                               }
                                                continueConsume = false
                                        } else {
                                                commitOffset = pq.commit()
@@ -1391,11 +1417,15 @@ func (pc *pushConsumer) consumeMessageOrderly(pq 
*processQueue, mq *primitive.Me
                                        commitOffset = pq.commit()
                                case Rollback:
                                        // pq.rollback
-                                       
time.Sleep(time.Duration(orderlyCtx.SuspendCurrentQueueTimeMillis) * 
time.Millisecond)
+                                       if !utils.WaitFor(pc.done, 
time.Duration(orderlyCtx.SuspendCurrentQueueTimeMillis)*time.Millisecond) {
+                                               return
+                                       }
                                        continueConsume = false
                                case SuspendCurrentQueueAMoment:
                                        if pc.checkReconsumeTimes(msgs) {
-                                               
time.Sleep(time.Duration(orderlyCtx.SuspendCurrentQueueTimeMillis) * 
time.Millisecond)
+                                               if !utils.WaitFor(pc.done, 
time.Duration(orderlyCtx.SuspendCurrentQueueTimeMillis)*time.Millisecond) {
+                                                       return
+                                               }
                                                continueConsume = false
                                        }
                                default:
@@ -1453,7 +1483,9 @@ func (pc *pushConsumer) getMaxReconsumeTimes() int32 {
 }
 
 func (pc *pushConsumer) tryLockLaterAndReconsume(mq *primitive.MessageQueue, 
delay int64) {
-       time.Sleep(time.Duration(delay) * time.Millisecond)
+       if !utils.WaitFor(pc.done, time.Duration(delay)*time.Millisecond) {
+               return
+       }
        if pc.lock(mq) == true {
                pc.submitConsumeRequestLater(10)
        } else {
@@ -1470,7 +1502,7 @@ func (pc *pushConsumer) 
submitConsumeRequestLater(suspendTimeMillis int64) {
        } else if suspendTimeMillis > 30000 {
                suspendTimeMillis = 30000
        }
-       time.Sleep(time.Duration(suspendTimeMillis) * time.Millisecond)
+       utils.WaitFor(pc.done, 
time.Duration(suspendTimeMillis)*time.Millisecond)
 }
 
 func (pc *pushConsumer) cleanExpiredMsg() {
diff --git a/consumer/push_consumer_test.go b/consumer/push_consumer_test.go
index 70779e5..89b823f 100644
--- a/consumer/push_consumer_test.go
+++ b/consumer/push_consumer_test.go
@@ -20,6 +20,7 @@ package consumer
 import (
        "context"
        "testing"
+       "time"
 
        "github.com/apache/rocketmq-client-go/v2/rlog"
 
@@ -41,11 +42,15 @@ func TestStart(t *testing.T) {
                        WithConsumerModel(BroadCasting),
                )
 
+               defer c.client.Shutdown() // Release the real construction 
reference replaced below.
                ctrl := gomock.NewController(t)
                defer ctrl.Finish()
 
                client := internal.NewMockRMQClient(ctrl)
                c.client = client
+               defer c.Shutdown()
+               client.EXPECT().Shutdown().Return()
+               client.EXPECT().UnregisterConsumer(gomock.Any()).Return()
 
                err := c.Subscribe("TopicTest", MessageSelector{}, func(ctx 
context.Context,
                        msgs ...*primitive.MessageExt) (ConsumeResult, error) {
@@ -80,19 +85,23 @@ func TestStart(t *testing.T) {
                client.EXPECT().UpdateTopicRouteInfo().AnyTimes().Return()
 
                Convey("test topic route info not found", func() {
-                       client.EXPECT().Shutdown().Return()
-                       
client.EXPECT().UnregisterConsumer(gomock.Any()).Return()
                        err = c.Start()
                        So(err.Error(), ShouldContainSubstring, "route info not 
found")
                })
 
                Convey("test topic route info found", func() {
-                       client.EXPECT().RebalanceImmediately().Return()
+                       rebalanced := make(chan struct{})
+                       client.EXPECT().RebalanceImmediately().Do(func() { 
close(rebalanced) })
                        client.EXPECT().CheckClientInBroker().Return()
                        
client.EXPECT().SendHeartbeatToAllBrokerWithLock().Return()
                        mockB4Start(c)
                        err = c.Start()
                        So(err, ShouldBeNil)
+                       select {
+                       case <-rebalanced:
+                       case <-time.After(time.Second):
+                               t.Fatal("startup rebalance did not complete")
+                       }
                })
        })
 }
diff --git a/consumer/resource_lifecycle_test.go 
b/consumer/resource_lifecycle_test.go
new file mode 100644
index 0000000..98e79f2
--- /dev/null
+++ b/consumer/resource_lifecycle_test.go
@@ -0,0 +1,59 @@
+/*
+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 consumer
+
+import (
+       "bytes"
+       "runtime/pprof"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/rocketmq-client-go/v2/primitive"
+       "github.com/stretchr/testify/require"
+)
+
+func consumerBackgroundWorkers() int {
+       var b bytes.Buffer
+       pprof.Lookup("goroutine").WriteTo(&b, 2)
+       return strings.Count(b.String(), "(*pushConsumer).Start.func") + 
strings.Count(b.String(), "(*statsItemSet).init.func")
+}
+
+func TestConsumerRepeatedLifecycleStopsBackgroundWorkers(t *testing.T) {
+       baseline := consumerBackgroundWorkers()
+       for i := 0; i < 10; i++ {
+               pc, err := NewPushConsumer(WithInstance(t.Name()), 
WithGroupName(t.Name()), WithConsumerModel(BroadCasting), 
WithConsumeTimeout(15*time.Minute), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+               require.NoError(t, err)
+               defer pc.Shutdown()
+               require.NoError(t, pc.Start())
+               require.NoError(t, pc.Shutdown())
+               require.Eventually(t, func() bool { return 
consumerBackgroundWorkers() <= baseline }, time.Second, time.Millisecond, 
"shutdown left cleanup/statistics workers waiting")
+       }
+}
+
+func TestShutdownInterruptsConsumerRetryDelay(t *testing.T) {
+       pc := &pushConsumer{done: make(chan struct{})}
+       exited := make(chan struct{})
+       go func() { pc.tryLockLaterAndReconsume(nil, 15*60*1000); close(exited) 
}()
+       close(pc.done)
+       select {
+       case <-exited:
+       case <-time.After(time.Second):
+               t.Fatal("retry delay ignored shutdown")
+       }
+}
diff --git a/consumer/shutdown_wait_test.go b/consumer/shutdown_wait_test.go
new file mode 100644
index 0000000..06ca95f
--- /dev/null
+++ b/consumer/shutdown_wait_test.go
@@ -0,0 +1,160 @@
+/*
+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 consumer
+
+import (
+       "context"
+       errors2 "github.com/apache/rocketmq-client-go/v2/errors"
+       "github.com/apache/rocketmq-client-go/v2/primitive"
+       "github.com/golang/mock/gomock"
+       "github.com/stretchr/testify/require"
+       "testing"
+       "time"
+)
+
+func TestShutdownReleasesPendingRebalance(t *testing.T) {
+       pc, err := NewPushConsumer(WithInstance(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer pc.Shutdown()
+       ctrl := gomock.NewController(t)
+       defer ctrl.Finish()
+       storage := NewMockOffsetStore(ctrl)
+       pc.storage = storage
+       storage.EXPECT().remove(gomock.Any()).AnyTimes()
+       // Model an in-flight rebalance whose offset lookup completes after 
shutdown.
+       entered, resume := make(chan struct{}), make(chan struct{})
+       first := true
+       storage.EXPECT().readWithException(gomock.Any(), 
gomock.Any()).DoAndReturn(func(_ *primitive.MessageQueue, _ readType) (int64, 
error) {
+               if first {
+                       first = false
+                       close(entered)
+                       <-resume
+               }
+               return 0, nil
+       }).AnyTimes()
+       queues := make([]*primitive.MessageQueue, cap(pc.prCh)+1)
+       for i := range queues {
+               queues[i] = &primitive.MessageQueue{Topic: "test", BrokerName: 
"broker", QueueId: i}
+       }
+       exited := make(chan struct{})
+       go func() { defer close(exited); pc.updateProcessQueueTable("test", 
queues) }()
+       <-entered
+       require.NoError(t, pc.Shutdown())
+       close(resume)
+       select {
+       case <-exited:
+       case <-time.After(time.Second):
+               t.Error("rebalance remains blocked sending to prCh after 
shutdown")
+       }
+       // Drain the orphaned channel so the reproduction itself does not leak.
+       for {
+               select {
+               case <-exited:
+                       pc.processQueueTable.Range(func(_, _ interface{}) bool 
{ t.Error("shutdown retained a newly published queue"); return true })
+                       return
+               case <-pc.prCh:
+               }
+       }
+}
+
+func TestShutdownReleasesPushConcurrencyWait(t *testing.T) {
+       pc, err := NewPushConsumer(WithInstance(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer pc.Shutdown()
+       pc.option.ConsumeMessageBatchMaxSize = 1
+       pq := newProcessQueue(false)
+       mq := &primitive.MessageQueue{Topic: "test"}
+       pq.msgCh <- []*primitive.MessageExt{{Message: primitive.Message{Topic: 
"test"}}}
+       slots := make(chan struct{}, 1)
+       slots <- struct{}{}
+       pc.crCh.Store("test", slots)
+       exited := make(chan struct{})
+       go func() { defer close(exited); pc.consumeMessageConcurrently(pq, mq) 
}()
+       time.Sleep(30 * time.Millisecond)
+       require.NoError(t, pc.Shutdown())
+       pq.WithDropped(true)
+       select {
+       case <-exited:
+       case <-time.After(time.Second):
+               t.Error("consumer dispatcher remains blocked on concurrency 
slot after shutdown")
+       }
+       <-slots
+       <-exited
+}
+
+func TestShutdownWakesPoll(t *testing.T) {
+       pc, err := NewPullConsumer(WithInstance(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer pc.Shutdown()
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+       exited := make(chan struct{})
+       go func() {
+               defer close(exited)
+               _, err := pc.Poll(ctx, time.Hour)
+               require.Equal(t, errors2.ErrService, err)
+       }()
+       require.NoError(t, pc.Shutdown())
+       select {
+       case <-exited:
+       case <-time.After(time.Second):
+               t.Error("Poll retains caller until its timeout even after 
Shutdown")
+       }
+       cancel()
+       <-exited
+}
+
+func TestPollBeforeAndAfterShutdown(t *testing.T) {
+       pc, err := NewPullConsumer(WithInstance(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer pc.Shutdown()
+       _, err = pc.Poll(context.Background(), time.Millisecond)
+       require.Equal(t, ErrNoNewMsg, err)
+       cr := &ConsumeRequest{processQueue: newProcessQueue(false), msgList: 
[]*primitive.MessageExt{{}}}
+       pc.consumeRequestCache <- cr
+       result, err := pc.Poll(context.Background(), time.Second)
+       require.NoError(t, err)
+       require.Same(t, cr, result)
+       pc.consumeRequestCache <- cr
+       require.NoError(t, pc.Shutdown())
+       _, err = pc.Poll(context.Background(), time.Hour)
+       require.Equal(t, errors2.ErrService, err, "closed Poll must not return 
a cached request")
+}
+
+func TestRebalanceDispatchesWhileOpen(t *testing.T) {
+       pc, err := NewPushConsumer(WithInstance(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer pc.Shutdown()
+       ctrl := gomock.NewController(t)
+       defer ctrl.Finish()
+       storage := NewMockOffsetStore(ctrl)
+       pc.storage = storage
+       mq := &primitive.MessageQueue{Topic: "test", BrokerName: "broker"}
+       storage.EXPECT().remove(mq)
+       storage.EXPECT().readWithException(mq, gomock.Any()).Return(int64(42), 
nil)
+       require.True(t, pc.updateProcessQueueTable("test", 
[]*primitive.MessageQueue{mq}))
+       select {
+       case pr := <-pc.prCh:
+               require.Equal(t, int64(42), pr.nextOffset)
+               require.Equal(t, *mq, *pr.mq)
+               require.False(t, pr.pq.IsDroppd())
+               pr.pq.WithDropped(true)
+       case <-time.After(time.Second):
+               t.Fatal("rebalance did not dispatch its queue")
+       }
+}
diff --git a/consumer/statistics.go b/consumer/statistics.go
index 19e0f3b..1ca2ce9 100644
--- a/consumer/statistics.go
+++ b/consumer/statistics.go
@@ -24,6 +24,7 @@ import (
        "sync/atomic"
        "time"
 
+       "github.com/apache/rocketmq-client-go/v2/internal/utils"
        "github.com/apache/rocketmq-client-go/v2/primitive"
        "github.com/apache/rocketmq-client-go/v2/rlog"
 )
@@ -211,7 +212,9 @@ func (sis *statsItemSet) init() {
        })
 
        go primitive.WithRecover(func() {
-               time.Sleep(nextMinutesTime().Sub(time.Now()))
+               if !utils.WaitFor(sis.closed, 
nextMinutesTime().Sub(time.Now())) {
+                       return
+               }
                ticker := time.NewTicker(time.Minute)
                defer ticker.Stop()
                for {
@@ -225,7 +228,9 @@ func (sis *statsItemSet) init() {
        })
 
        go primitive.WithRecover(func() {
-               time.Sleep(nextHourTime().Sub(time.Now()))
+               if !utils.WaitFor(sis.closed, nextHourTime().Sub(time.Now())) {
+                       return
+               }
                ticker := time.NewTicker(time.Hour)
                defer ticker.Stop()
                for {
@@ -239,7 +244,9 @@ func (sis *statsItemSet) init() {
        })
 
        go primitive.WithRecover(func() {
-               time.Sleep(nextMonthTime().Sub(time.Now()))
+               if !utils.WaitFor(sis.closed, nextMonthTime().Sub(time.Now())) {
+                       return
+               }
                ticker := time.NewTicker(24 * time.Hour)
                defer ticker.Stop()
                for {
diff --git a/internal/client.go b/internal/client.go
index c302cf7..3b17632 100644
--- a/internal/client.go
+++ b/internal/client.go
@@ -177,7 +177,6 @@ type rmqClient struct {
 
        remoteClient remote.RemotingClient
        hbMutex      sync.Mutex
-       close        bool
        rbMutex      sync.Mutex
        done         chan struct{}
        shutdownOnce sync.Once
@@ -197,20 +196,53 @@ var clientMapMu sync.Mutex
 
 // GetOrNewRocketMQClient acquires a reference that must be released by 
Shutdown,
 // whether or not the caller starts the client.
-func GetOrNewRocketMQClient(option ClientOptions, callbackCh chan interface{}) 
RMQClient {
+// callbackStops optionally cancels callback delivery when its producer stops,
+// even if other owners keep the shared client alive.
+func GetOrNewRocketMQClient(option ClientOptions, callbackCh chan interface{}, 
callbackStops ...<-chan struct{}) RMQClient {
+       var callbackStop <-chan struct{}
+       if len(callbackStops) > 0 {
+               callbackStop = callbackStops[0]
+       }
        client := &rmqClient{
                option:       option,
                remoteClient: 
remote.NewRemotingClient(option.RemotingClientConfig),
                done:         make(chan struct{}),
                ready:        make(chan struct{}),
        }
+       clientID := client.ClientID()
+       var cloned *namesrvs
        clientMapMu.Lock()
-       actual, loaded := clientMap.LoadOrStore(client.ClientID(), client)
-       client = actual.(*rmqClient)
+       actual, loaded := clientMap.Load(clientID)
+       if !loaded && option.Namesrv.(*namesrvs).bundleClient != nil {
+               // Snapshot copying can wait for discovery to release the 
NameServer
+               // lock. Never hold the global client registry lock while 
waiting.
+               clientMapMu.Unlock()
+               cloned = option.Namesrv.(*namesrvs).cloneForClient()
+               clientMapMu.Lock()
+               // Another caller may have published this client ID in the 
meantime.
+               actual, loaded = clientMap.Load(clientID)
+       }
+       if loaded {
+               client = actual.(*rmqClient)
+       } else {
+               srvs := option.Namesrv.(*namesrvs)
+               if cloned != nil {
+                       srvs = cloned
+               }
+               // Ownership and publication stay atomic. Never rebind an old 
instance's
+               // NameServer, including while its shutdown is still in flight.
+               srvs.bundleClient = client
+               client.option.Namesrv = srvs
+               clientMap.Store(clientID, client)
+       }
        client.refs++
        clientMapMu.Unlock()
 
        if loaded {
+               if cloned != nil {
+                       // A concurrent creator won; dispose the unused private 
transport.
+                       cloned.nameSrvClient.ShutDown()
+               }
                // Do not expose a shared client before its request handlers 
are ready.
                <-client.ready
                // compare namesrv address
@@ -265,7 +297,9 @@ func GetOrNewRocketMQClient(option ClientOptions, 
callbackCh chan interface{}) R
                                rlog.Warning("checkTransactionState, pick 
producer group failed", nil)
                                return nil
                        }
-                       if option.GroupName != group {
+                       // Capture only the current client. The input options 
may reference a
+                       // retired NameServer and keep every previous 
generation reachable.
+                       if client.option.GroupName != group {
                                rlog.Warning("producer group is not equal", nil)
                                return nil
                        }
@@ -274,7 +308,11 @@ func GetOrNewRocketMQClient(option ClientOptions, 
callbackCh chan interface{}) R
                                Msg:    msgExt,
                                Header: *header,
                        }
-                       callbackCh <- callback
+                       select {
+                       case callbackCh <- callback:
+                       case <-callbackStop:
+                       case <-client.done:
+                       }
                        return nil
                })
 
@@ -419,8 +457,6 @@ func GetOrNewRocketMQClient(option ClientOptions, 
callbackCh chan interface{}) R
                        }
                        return res
                })
-               // Only the creator binds the NameServer; reuse must not race 
with readers.
-               client.GetNameSrv().(*namesrvs).bundleClient = client
                close(client.ready)
        }
        return client
@@ -435,7 +471,9 @@ func (c *rmqClient) Start() {
                        op := func() {
                                c.GetNameSrv().UpdateNameServerAddress()
                        }
-                       time.Sleep(10 * time.Second)
+                       if !utils.WaitFor(c.done, 10*time.Second) {
+                               return
+                       }
                        op()
 
                        ticker := time.NewTicker(2 * time.Minute)
@@ -459,7 +497,9 @@ func (c *rmqClient) Start() {
                        op := func() {
                                c.UpdateTopicRouteInfo()
                        }
-                       time.Sleep(10 * time.Millisecond)
+                       if !utils.WaitFor(c.done, 10*time.Millisecond) {
+                               return
+                       }
                        op()
 
                        ticker := time.NewTicker(_PullNameServerInterval)
@@ -483,7 +523,9 @@ func (c *rmqClient) Start() {
                                c.SendHeartbeatToAllBrokerWithLock()
                        }
 
-                       time.Sleep(time.Second)
+                       if !utils.WaitFor(c.done, time.Second) {
+                               return
+                       }
                        op()
 
                        ticker := time.NewTicker(_HeartbeatBrokerInterval)
@@ -515,7 +557,9 @@ func (c *rmqClient) Start() {
                                        return true
                                })
                        }
-                       time.Sleep(10 * time.Second)
+                       if !utils.WaitFor(c.done, 10*time.Second) {
+                               return
+                       }
                        op()
 
                        ticker := time.NewTicker(_PersistOffsetInterval)
@@ -572,11 +616,22 @@ func (c *rmqClient) Shutdown() {
                        "clientID": c.ClientID(),
                })
                close(c.done)
-               c.close = true
                c.remoteClient.ShutDown()
+               if srvs, ok := c.GetNameSrv().(*namesrvs); ok && srvs != nil {
+                       srvs.nameSrvClient.ShutDown()
+               }
        })
 }
 
+func (c *rmqClient) isClosed() bool {
+       select {
+       case <-c.done:
+               return true
+       default:
+               return false
+       }
+}
+
 func (c *rmqClient) ClientID() string {
        id := c.option.ClientIP + "@"
        if c.option.InstanceName == "DEFAULT" {
@@ -592,7 +647,7 @@ func (c *rmqClient) ClientID() string {
 
 func (c *rmqClient) InvokeSync(ctx context.Context, addr string, request 
*remote.RemotingCommand,
        timeoutMillis time.Duration) (*remote.RemotingCommand, error) {
-       if c.close {
+       if c.isClosed() {
                return nil, ErrServiceState
        }
        var cancel context.CancelFunc
@@ -603,7 +658,7 @@ func (c *rmqClient) InvokeSync(ctx context.Context, addr 
string, request *remote
 
 func (c *rmqClient) InvokeAsync(ctx context.Context, addr string, request 
*remote.RemotingCommand,
        f func(*remote.RemotingCommand, error)) error {
-       if c.close {
+       if c.isClosed() {
                return ErrServiceState
        }
        return c.remoteClient.InvokeAsync(ctx, addr, request, func(future 
*remote.ResponseFuture) {
@@ -614,7 +669,7 @@ func (c *rmqClient) InvokeAsync(ctx context.Context, addr 
string, request *remot
 
 func (c *rmqClient) InvokeOneWay(ctx context.Context, addr string, request 
*remote.RemotingCommand,
        timeoutMillis time.Duration) error {
-       if c.close {
+       if c.isClosed() {
                return ErrServiceState
        }
        return c.remoteClient.InvokeOneWay(ctx, addr, request)
diff --git a/internal/client_lifecycle_test.go 
b/internal/client_lifecycle_test.go
index a2d7502..bbd5d7f 100644
--- a/internal/client_lifecycle_test.go
+++ b/internal/client_lifecycle_test.go
@@ -18,8 +18,18 @@ limitations under the License.
 package internal
 
 import (
+       "context"
+       "encoding/binary"
+       "encoding/json"
+       "io"
+       "net"
+       "runtime"
        "sync"
        "testing"
+       "time"
+
+       "github.com/apache/rocketmq-client-go/v2/internal/remote"
+       "github.com/golang/mock/gomock"
 
        "github.com/apache/rocketmq-client-go/v2/primitive"
        "github.com/stretchr/testify/require"
@@ -67,8 +77,11 @@ func TestClientOwnershipConcurrentAcquireAndRelease(t 
*testing.T) {
 }
 
 func TestClientOwnershipOldCleanupKeepsReplacement(t *testing.T) {
-       options := ownershipTestOptions(t)
+       options, stopServer := namesrvRebuildTestOptions(t)
+       defer stopServer()
        old := GetOrNewRocketMQClient(options, nil).(*rmqClient)
+       defer old.Shutdown()
+       assertNamesrvRebuildRoute(t, old)
        old.Shutdown()
        replacement := GetOrNewRocketMQClient(options, nil).(*rmqClient)
        defer replacement.Shutdown()
@@ -77,6 +90,15 @@ func TestClientOwnershipOldCleanupKeepsReplacement(t 
*testing.T) {
        stored, exists := clientMap.Load(replacement.ClientID())
        require.True(t, exists)
        require.Same(t, replacement, stored)
+       assertNamesrvRebuildRoute(t, replacement)
+       require.NotSame(t, old.GetNameSrv(), replacement.GetNameSrv())
+       require.Same(t, old.GetNameSrv().(*namesrvs).remotingConfig, 
replacement.GetNameSrv().(*namesrvs).remotingConfig)
+       require.Same(t, old, old.GetNameSrv().(*namesrvs).bundleClient)
+       // Reusing the options retained inside a replacement must work as well.
+       replacement.Shutdown()
+       third := GetOrNewRocketMQClient(replacement.option, nil).(*rmqClient)
+       defer third.Shutdown()
+       assertNamesrvRebuildRoute(t, third)
 }
 
 func TestClientOwnershipKeepsResolverSnapshot(t *testing.T) {
@@ -94,3 +116,261 @@ func TestClientOwnershipKeepsResolverSnapshot(t 
*testing.T) {
        require.Equal(t, []string{"127.0.0.2:9876", "127.0.0.1:9876"}, 
addresses,
                "compatibility checks must not sort a resolver-owned snapshot 
in place")
 }
+
+func TestClientOwnershipReplacementDuringOldShutdown(t *testing.T) {
+       options, stopServer := namesrvRebuildTestOptions(t)
+       defer stopServer()
+       old := GetOrNewRocketMQClient(options, nil).(*rmqClient)
+       defer old.Shutdown()
+       assertNamesrvRebuildRoute(t, old)
+       ctrl := gomock.NewController(t)
+       defer ctrl.Finish()
+       transport := remote.NewMockRemotingClient(ctrl)
+       entered, resume, stopped := make(chan struct{}), make(chan struct{}), 
make(chan struct{})
+       transport.EXPECT().ShutDown().Do(func() { close(entered); <-resume })
+       old.remoteClient = transport
+       go func() { old.Shutdown(); close(stopped) }()
+       var releaseOnce sync.Once
+       release := func() { releaseOnce.Do(func() { close(resume) }); <-stopped 
}
+       defer release()
+       <-entered
+       replacement := GetOrNewRocketMQClient(options, nil).(*rmqClient)
+       defer replacement.Shutdown()
+       assertNamesrvRebuildRoute(t, replacement)
+       release()
+       assertNamesrvRebuildRoute(t, replacement)
+       ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+       defer cancel()
+       _, err := 
old.GetNameSrv().(*namesrvs).queryTopicRouteInfoWithContext(ctx, "test")
+       require.Error(t, err, "the old transport must remain closed")
+}
+
+func assertNamesrvRebuildRoute(t *testing.T, client *rmqClient) {
+       t.Helper()
+       ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+       defer cancel()
+       route, _, err := 
client.GetNameSrv().(*namesrvs).updateTopicRouteInfoWithContext(ctx, "test", 
"", 0)
+       require.NoError(t, err)
+       require.Equal(t, "broker:1", route.OrderTopicConf)
+}
+
+// Reply to real framed route requests, including checking that ACL survives a 
rebuild.
+func namesrvRebuildTestOptions(t *testing.T) (ClientOptions, func()) {
+       t.Helper()
+       listener, err := net.Listen("tcp", "127.0.0.1:0")
+       require.NoError(t, err)
+       var wg sync.WaitGroup
+       var conns sync.Map
+       wg.Add(1)
+       go func() {
+               defer wg.Done()
+               for {
+                       conn, err := listener.Accept()
+                       if err != nil {
+                               return
+                       }
+                       conns.Store(conn, true)
+                       wg.Add(1)
+                       go func() {
+                               defer wg.Done()
+                               defer conn.Close()
+                               defer conns.Delete(conn)
+                               _ = conn.SetDeadline(time.Now().Add(5 * 
time.Second))
+                               for {
+                                       var size uint32
+                                       if err := binary.Read(conn, 
binary.BigEndian, &size); err != nil {
+                                               return
+                                       }
+                                       if size < 4 || size > 1024*1024 {
+                                               t.Error("invalid request size")
+                                               return
+                                       }
+                                       frame := make([]byte, size)
+                                       if _, err := io.ReadFull(conn, frame); 
err != nil {
+                                               return
+                                       }
+                                       headerSize := 
binary.BigEndian.Uint32(frame[:4])
+                                       if headerSize > size-4 {
+                                               t.Error("invalid JSON header 
size")
+                                               return
+                                       }
+                                       var request remote.RemotingCommand
+                                       if err := 
json.Unmarshal(frame[4:4+headerSize], &request); err != nil {
+                                               t.Error(err)
+                                               return
+                                       }
+                                       if request.Code != 
ReqGetRouteInfoByTopic || request.ExtFields["topic"] != "test" ||
+                                               request.ExtFields["AccessKey"] 
!= "test-key" || request.ExtFields["SecurityToken"] != "test-token" || 
request.ExtFields["Signature"] == "" {
+                                               t.Error("route request lost its 
topic or ACL credentials")
+                                               return
+                                       }
+                                       response := 
remote.NewRemotingCommand(ResSuccess, nil, 
[]byte(`{"orderTopicConf":"broker:1","queueDatas":[],"brokerDatas":[]}`))
+                                       response.Opaque = request.Opaque
+                                       response.Flag = remote.ResponseType
+                                       if err := response.WriteTo(conn); err 
!= nil {
+                                               return
+                                       }
+                               }
+                       }()
+               }
+       }()
+       stop := func() {
+               listener.Close()
+               conns.Range(func(key, _ interface{}) bool { 
key.(net.Conn).Close(); return true })
+               wg.Wait()
+       }
+       options := DefaultClientOptions()
+       options.InstanceName = t.Name()
+       config := remote.DefaultRemotingClientConfig
+       config.ConnectionTimeout = time.Second
+       ns, err := 
NewNamesrv(primitive.NewPassthroughResolver([]string{listener.Addr().String()}),
 &config)
+       if err != nil {
+               stop()
+               t.Fatal(err)
+       }
+       ns.SetCredentials(primitive.Credentials{AccessKey: "test-key", 
SecretKey: "test-secret", SecurityToken: "test-token"})
+       options.Namesrv = ns
+       return options, stop
+}
+
+// Observe entry to Lock without relying on sleeps to schedule the clone 
attempt.
+type snapshotAttemptLocker struct {
+       sync.Locker
+       attempts chan struct{}
+}
+
+func (l *snapshotAttemptLocker) Lock() {
+       select {
+       case l.attempts <- struct{}{}:
+       default:
+       }
+       l.Locker.Lock()
+}
+
+type blockedDiscoveryResolver struct {
+       primitive.NsResolver
+       entered chan struct{}
+       resume  chan struct{}
+       once    sync.Once
+}
+
+func (r *blockedDiscoveryResolver) Resolve() []string {
+       r.once.Do(func() { close(r.entered) })
+       <-r.resume
+       return r.NsResolver.Resolve()
+}
+
+func TestClientOwnershipSlowSnapshotDoesNotBlockOtherClients(t *testing.T) {
+       options := ownershipTestOptions(t)
+       old := GetOrNewRocketMQClient(options, nil).(*rmqClient)
+       old.Shutdown()
+       ns := options.Namesrv.(*namesrvs)
+       resolver := &blockedDiscoveryResolver{
+               NsResolver: ns.resolver, entered: make(chan struct{}), resume: 
make(chan struct{}),
+       }
+       ns.resolver = resolver
+       lock := &snapshotAttemptLocker{Locker: ns.lock, attempts: make(chan 
struct{}, 4)}
+       ns.lock = lock
+
+       closingOptions := ownershipTestOptions(t)
+       closingOptions.InstanceName += "-closing"
+       closing := GetOrNewRocketMQClient(closingOptions, nil)
+       defer closing.Shutdown()
+       creatingOptions := ownershipTestOptions(t)
+       creatingOptions.InstanceName += "-creating"
+       // A competing creator uses fresh discovery state for the same client 
ID.
+       winnerOptions := ownershipTestOptions(t)
+       var workers sync.WaitGroup
+       var resumeOnce sync.Once
+       resume := func() { resumeOnce.Do(func() { close(resolver.resume) }) }
+       defer func() { resume(); workers.Wait() }()
+       workers.Add(1)
+       go func() { defer workers.Done(); ns.UpdateNameServerAddress() }()
+       select {
+       case <-resolver.entered:
+       case <-time.After(time.Second):
+               t.Fatal("discovery did not start")
+       }
+       <-lock.attempts // The refresh has acquired the NameServer lock.
+
+       rebuilt := make(chan *rmqClient, 1)
+       workers.Add(1)
+       go func() {
+               defer workers.Done()
+               client := GetOrNewRocketMQClient(options, nil).(*rmqClient)
+               defer client.Shutdown()
+               rebuilt <- client
+       }()
+       select {
+       case <-lock.attempts: // The rebuilding client is now trying to copy 
the snapshot.
+       case <-time.After(time.Second):
+               t.Fatal("rebuild did not attempt to acquire the NameServer 
lock")
+       }
+
+       closed, created := make(chan struct{}), make(chan struct{})
+       winnerReady, releaseWinner := make(chan *rmqClient, 1), make(chan 
struct{})
+       defer close(releaseWinner)
+       workers.Add(3)
+       go func() { defer workers.Done(); closing.Shutdown(); close(closed) }()
+       go func() {
+               defer workers.Done()
+               client := GetOrNewRocketMQClient(creatingOptions, nil)
+               client.Shutdown()
+               close(created)
+       }()
+       go func() {
+               defer workers.Done()
+               client := GetOrNewRocketMQClient(winnerOptions, 
nil).(*rmqClient)
+               defer client.Shutdown()
+               winnerReady <- client
+               <-releaseWinner
+       }()
+       for name, done := range map[string]<-chan struct{}{"shutdown": closed, 
"creation": created} {
+               select {
+               case <-done:
+               case <-time.After(time.Second):
+                       t.Errorf("unrelated client %s blocked on slow 
discovery", name)
+               }
+       }
+       var winner *rmqClient
+       select {
+       case winner = <-winnerReady:
+       case <-time.After(time.Second):
+               t.Error("same-ID creator with fresh discovery state was 
blocked")
+       }
+       resume()
+       replacement := <-rebuilt
+       if winner != nil {
+               require.Same(t, winner, replacement, "recheck the client table 
after copying the snapshot")
+               require.Same(t, winner, 
winner.GetNameSrv().(*namesrvs).bundleClient)
+       }
+       require.NotSame(t, ns, replacement.GetNameSrv())
+       require.Same(t, old, ns.bundleClient, "the old NameServer binding must 
remain unchanged")
+}
+
+func TestClientOwnershipRebuildDoesNotRetainRetiredClients(t *testing.T) {
+       client, collected := rebuiltClientWithRetiredCaches(t)
+       defer client.Shutdown()
+       require.Eventually(t, func() bool {
+               runtime.GC()
+               return len(collected) == 3
+       }, 3*time.Second, 10*time.Millisecond, "the live replacement must not 
retain retired clients through its request handlers")
+       runtime.KeepAlive(client)
+}
+
+func rebuiltClientWithRetiredCaches(t *testing.T) (*rmqClient, <-chan 
struct{}) {
+       t.Helper()
+       collected := make(chan struct{}, 3)
+       client := GetOrNewRocketMQClient(ownershipTestOptions(t), 
nil).(*rmqClient)
+       for generation := 0; generation < 3; generation++ {
+               // Use a separately allocated leaf object: finalizers on the 
client itself
+               // would interact with its intentional internal reference 
cycles.
+               marker := new([1024]byte)
+               runtime.SetFinalizer(marker, func(*[1024]byte) { collected <- 
struct{}{} })
+               
client.GetNameSrv().(*namesrvs).routeDataMap.Store("retired-cache", marker)
+               options := client.option
+               client.Shutdown()
+               client = GetOrNewRocketMQClient(options, nil).(*rmqClient)
+       }
+       return client, collected
+}
diff --git a/internal/namesrv.go b/internal/namesrv.go
index 84042cd..91e0696 100644
--- a/internal/namesrv.go
+++ b/internal/namesrv.go
@@ -93,7 +93,9 @@ type namesrvs struct {
 
        lockNamesrv sync.Mutex
 
-       nameSrvClient remote.RemotingClient
+       nameSrvClient  remote.RemotingClient
+       remotingConfig *remote.RemotingClientConfig
+       credentials    *primitive.Credentials // protected by lock
 
        resolver primitive.NsResolver
 }
@@ -119,15 +121,32 @@ func NewNamesrv(resolver primitive.NsResolver, config 
*remote.RemotingClientConf
        if err := primitive.NamesrvAddr(addr).Check(); err != nil {
                return nil, err
        }
-       nameSrvClient := remote.NewRemotingClient(config)
+       return newNamesrvWithAddresses(addr, resolver, config), nil
+}
+
+func newNamesrvWithAddresses(addr []string, resolver primitive.NsResolver, 
config *remote.RemotingClientConfig) *namesrvs {
        return &namesrvs{
                srvs:             append([]string(nil), addr...),
                lock:             new(sync.Mutex),
-               nameSrvClient:    nameSrvClient,
+               nameSrvClient:    remote.NewRemotingClient(config),
+               remotingConfig:   config,
                brokerVersionMap: make(map[string]map[string]int32, 0),
                brokerLock:       new(sync.RWMutex),
                resolver:         resolver,
-       }, nil
+       }
+}
+
+// cloneForClient retains discovery configuration but gives a new client its 
own
+// transport and route caches. Do not resolve again: discovery can temporarily 
be
+// unavailable, while the last known address snapshot is still usable.
+func (s *namesrvs) cloneForClient() *namesrvs {
+       s.lock.Lock()
+       defer s.lock.Unlock()
+       cloned := newNamesrvWithAddresses(s.srvs, s.resolver, s.remotingConfig)
+       if s.credentials != nil {
+               cloned.SetCredentials(*s.credentials)
+       }
+       return cloned
 }
 
 // getNameServerAddress return namesrv using round-robin strategy.
@@ -158,6 +177,9 @@ func (s *namesrvs) String() string {
        return strings.Join(s.AddrList(), ";")
 }
 func (s *namesrvs) SetCredentials(credentials primitive.Credentials) {
+       s.lock.Lock()
+       defer s.lock.Unlock()
+       s.credentials = &credentials
        s.nameSrvClient.RegisterInterceptor(remote.ACLInterceptor(credentials))
 }
 
diff --git a/internal/remote/future.go b/internal/remote/future.go
index a7d3268..a74b314 100644
--- a/internal/remote/future.go
+++ b/internal/remote/future.go
@@ -31,6 +31,7 @@ type ResponseFuture struct {
        callback        func(*ResponseFuture)
        Done            chan bool
        callbackOnce    sync.Once
+       completeOnce    sync.Once
        ctx             context.Context
 }
 
@@ -52,17 +53,22 @@ func (r *ResponseFuture) executeInvokeCallback() {
        })
 }
 
+// complete publishes exactly one result before waking waiters. Callbacks run
+// outside transport locks, in the goroutine owning the asynchronous request.
+func (r *ResponseFuture) complete(cmd *RemotingCommand, err error) {
+       r.completeOnce.Do(func() {
+               r.ResponseCommand, r.Err = cmd, err
+               if r.Done != nil {
+                       close(r.Done)
+               }
+       })
+}
+
 func (r *ResponseFuture) waitResponse() (*RemotingCommand, error) {
-       var (
-               cmd *RemotingCommand
-               err error
-       )
        select {
        case <-r.Done:
-               cmd, err = r.ResponseCommand, r.Err
        case <-r.ctx.Done():
-               err = errors.ErrRequestTimeout
-               r.Err = err
+               r.complete(nil, errors.ErrRequestTimeout)
        }
-       return cmd, err
+       return r.ResponseCommand, r.Err
 }
diff --git a/internal/remote/remote_client.go b/internal/remote/remote_client.go
index 55a2646..74a736a 100644
--- a/internal/remote/remote_client.go
+++ b/internal/remote/remote_client.go
@@ -21,6 +21,7 @@ import (
        "bytes"
        "context"
        "encoding/binary"
+       "errors"
        "github.com/apache/rocketmq-client-go/v2/internal/utils"
        "io"
        "net"
@@ -54,12 +55,17 @@ type RemotingClient interface {
 
 var _ RemotingClient = &remotingClient{}
 
+var errClientClosed = errors.New("remoting client is shut down")
+
 type remotingClient struct {
        responseTable    sync.Map
        connectionTable  sync.Map
        config           *RemotingClientConfig
        processors       map[int16]ClientRequestFunc
        connectionLocker sync.Mutex
+       lifecycleMu      sync.Mutex
+       closed           bool // protected by lifecycleMu
+       done             chan struct{}
        interceptor      primitive.Interceptor
 }
 
@@ -84,6 +90,7 @@ func NewRemotingClient(config *RemotingClientConfig) 
*remotingClient {
        return &remotingClient{
                processors: make(map[int16]ClientRequestFunc),
                config:     config,
+               done:       make(chan struct{}),
        }
 }
 
@@ -100,7 +107,9 @@ func (c *remotingClient) InvokeSync(ctx context.Context, 
addr string, request *R
 
        resp := NewResponseFuture(ctx, request.Opaque, nil)
 
-       c.responseTable.Store(resp.Opaque, resp)
+       if err := c.registerResponse(resp); err != nil {
+               return nil, err
+       }
        defer c.responseTable.Delete(request.Opaque)
 
        err = c.sendRequest(ctx, conn, request)
@@ -114,7 +123,9 @@ func (c *remotingClient) InvokeSync(ctx context.Context, 
addr string, request *R
 // InvokeAsync send request without blocking, just return immediately.
 func (c *remotingClient) InvokeAsync(ctx context.Context, addr string, request 
*RemotingCommand, callback func(*ResponseFuture)) error {
        resp := NewResponseFuture(ctx, request.Opaque, callback)
-       c.responseTable.Store(resp.Opaque, resp)
+       if err := c.registerResponse(resp); err != nil {
+               return err
+       }
 
        go primitive.WithRecover(func() {
                defer resp.executeInvokeCallback()
@@ -122,12 +133,12 @@ func (c *remotingClient) InvokeAsync(ctx context.Context, 
addr string, request *
 
                conn, err := c.connect(ctx, addr)
                if err != nil {
-                       resp.Err = err
+                       resp.complete(nil, err)
                        return
                }
                err = c.sendRequest(ctx, conn, request)
                if err != nil {
-                       resp.Err = err
+                       resp.complete(nil, err)
                        return
                }
                c.receiveAsync(resp)
@@ -151,18 +162,50 @@ func (c *remotingClient) InvokeOneWay(ctx 
context.Context, addr string, request
        return c.sendRequest(ctx, conn, request)
 }
 
+func (c *remotingClient) registerResponse(resp *ResponseFuture) error {
+       c.lifecycleMu.Lock()
+       defer c.lifecycleMu.Unlock()
+       if c.closed {
+               return errClientClosed
+       }
+       c.responseTable.Store(resp.Opaque, resp)
+       return nil
+}
+
 func (c *remotingClient) connect(ctx context.Context, addr string) 
(*tcpConnWrapper, error) {
-       // it needs additional locker.
        c.connectionLocker.Lock()
        defer c.connectionLocker.Unlock()
+       c.lifecycleMu.Lock()
+       if c.closed {
+               c.lifecycleMu.Unlock()
+               return nil, errClientClosed
+       }
        conn, ok := c.connectionTable.Load(addr)
+       c.lifecycleMu.Unlock()
        if ok {
                return conn.(*tcpConnWrapper), nil
        }
-       tcpConn, err := initConn(ctx, addr, c.config)
+
+       // Cancel a pending TCP dial or TLS handshake when the transport stops.
+       dialCtx, cancel := context.WithCancel(ctx)
+       defer cancel()
+       go func() {
+               select {
+               case <-c.done:
+                       cancel()
+               case <-dialCtx.Done():
+               }
+       }()
+       tcpConn, err := initConn(dialCtx, addr, c.config)
        if err != nil {
                return nil, err
        }
+       c.lifecycleMu.Lock()
+       defer c.lifecycleMu.Unlock()
+       if c.closed {
+               tcpConn.destroy()
+               return nil, errClientClosed
+       }
        c.connectionTable.Store(addr, tcpConn)
        go primitive.WithRecover(func() {
                c.receiveResponse(tcpConn)
@@ -243,13 +286,7 @@ func (c *remotingClient) processCMD(cmd *RemotingCommand, 
r *tcpConnWrapper) {
                if exist {
                        c.responseTable.Delete(cmd.Opaque)
                        responseFuture := resp.(*ResponseFuture)
-                       go primitive.WithRecover(func() {
-                               responseFuture.ResponseCommand = cmd
-                               if responseFuture.Done != nil {
-                                       close(responseFuture.Done)
-                               }
-                               responseFuture.executeInvokeCallback()
-                       })
+                       responseFuture.complete(cmd, nil)
                }
        } else {
                f := c.processors[cmd.Code]
@@ -370,11 +407,20 @@ func (c *remotingClient) closeConnection(toCloseConn 
*tcpConnWrapper) {
 }
 
 func (c *remotingClient) ShutDown() {
+       c.lifecycleMu.Lock()
+       defer c.lifecycleMu.Unlock()
+       if c.closed {
+               return
+       }
+       c.closed = true
+       close(c.done)
        c.responseTable.Range(func(key, value interface{}) bool {
+               value.(*ResponseFuture).complete(nil, errClientClosed)
                c.responseTable.Delete(key)
                return true
        })
        c.connectionTable.Range(func(key, value interface{}) bool {
+               c.connectionTable.Delete(key)
                conn := value.(*tcpConnWrapper)
                err := conn.destroy()
                if err != nil {
diff --git a/internal/remote/remote_client_test.go 
b/internal/remote/remote_client_test.go
index b7171c4..5d51284 100644
--- a/internal/remote/remote_client_test.go
+++ b/internal/remote/remote_client_test.go
@@ -283,13 +283,14 @@ func TestInvokeAsyncTimeout(t *testing.T) {
        var wg sync.WaitGroup
        wg.Add(1)
        client := NewRemotingClient(nil)
+       defer client.ShutDown()
+       ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+       defer cancel()
 
        var clientSend sync.WaitGroup // blocking client send message until the 
server listen success.
        clientSend.Add(1)
        go func() {
                clientSend.Wait()
-               ctx, cancel := context.WithTimeout(context.Background(), 
time.Duration(10*time.Second))
-               defer cancel()
                err := client.InvokeAsync(ctx, addr, clientSendRemtingCommand,
                        func(r *ResponseFuture) {
                                assert.NotNil(t, r.Err)
diff --git a/internal/remote/resource_lifecycle_test.go 
b/internal/remote/resource_lifecycle_test.go
new file mode 100644
index 0000000..adbe432
--- /dev/null
+++ b/internal/remote/resource_lifecycle_test.go
@@ -0,0 +1,212 @@
+/*
+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 remote
+
+import (
+       "bufio"
+       "context"
+       "io"
+       "io/ioutil"
+       "net"
+       "net/http"
+       "net/http/httptest"
+       "sync"
+       "sync/atomic"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func awaitShutdownSignal(t *testing.T, signal <-chan struct{}) {
+       t.Helper()
+       select {
+       case <-signal:
+       case <-time.After(2 * time.Second):
+               t.Fatal("shutdown did not release the waiting operation")
+       }
+}
+
+func TestShutdownCompletesPendingRequests(t *testing.T) {
+       // Reuse the process while repeatedly constructing and disposing 
transports.
+       for cycle := 0; cycle < 10; cycle++ {
+               listener, err := net.Listen("tcp", "127.0.0.1:0")
+               require.NoError(t, err)
+               defer listener.Close()
+               client := NewRemotingClient(nil)
+               defer client.ShutDown()
+               requests := make(chan struct{}, 2)
+               disconnected := make(chan struct{})
+               go func() {
+                       defer close(disconnected)
+                       conn, err := listener.Accept()
+                       if err != nil {
+                               return
+                       }
+                       defer conn.Close()
+                       scanner := client.createScanner(conn)
+                       for scanner.Scan() {
+                               requests <- struct{}{}
+                       }
+               }()
+               syncDone := make(chan struct{})
+               go func() {
+                       defer close(syncDone)
+                       _, err := client.InvokeSync(context.Background(), 
listener.Addr().String(), NewRemotingCommand(10, nil, nil))
+                       if err == nil {
+                               t.Error("pending sync request succeeded after 
shutdown")
+                       }
+               }()
+               asyncDone := make(chan struct{})
+               var callbacks int32
+               err = client.InvokeAsync(context.Background(), 
listener.Addr().String(), NewRemotingCommand(10, nil, nil), func(f 
*ResponseFuture) {
+                       atomic.AddInt32(&callbacks, 1)
+                       if f.Err == nil {
+                               t.Error("pending async request succeeded after 
shutdown")
+                       }
+                       // A callback may reenter Shutdown; it must not run 
under the lifecycle lock.
+                       client.ShutDown()
+                       close(asyncDone)
+               })
+               require.NoError(t, err)
+               awaitShutdownSignal(t, requests)
+               awaitShutdownSignal(t, requests)
+               client.ShutDown()
+               awaitShutdownSignal(t, syncDone)
+               awaitShutdownSignal(t, asyncDone)
+               awaitShutdownSignal(t, disconnected)
+               require.EqualValues(t, 1, atomic.LoadInt32(&callbacks))
+               client.responseTable.Range(func(_, _ interface{}) bool { 
t.Error("pending response retained"); return true })
+               client.connectionTable.Range(func(_, _ interface{}) bool { 
t.Error("connection retained"); return true })
+               _, err = client.connect(context.Background(), 
listener.Addr().String())
+               require.Equal(t, errClientClosed, err)
+               err = client.InvokeAsync(context.Background(), 
listener.Addr().String(), NewRemotingCommand(10, nil, nil), nil)
+               require.Equal(t, errClientClosed, err)
+       }
+}
+
+func TestShutdownCancelsTLSHandshake(t *testing.T) {
+       listener, err := net.Listen("tcp", "127.0.0.1:0")
+       require.NoError(t, err)
+       defer listener.Close()
+       config := DefaultRemotingClientConfig
+       config.UseTls = true
+       config.ConnectionTimeout = time.Minute
+       client := NewRemotingClient(&config)
+       defer client.ShutDown()
+       accepted := make(chan net.Conn, 1)
+       go func() {
+               conn, err := listener.Accept()
+               if err == nil {
+                       accepted <- conn
+               }
+       }()
+       invoked := make(chan struct{})
+       go func() {
+               defer close(invoked)
+               _, err := client.connect(context.Background(), 
listener.Addr().String())
+               if err == nil {
+                       t.Error("stalled TLS handshake unexpectedly succeeded")
+               }
+       }()
+       var conn net.Conn
+       select {
+       case conn = <-accepted:
+       case <-time.After(2 * time.Second):
+               t.Fatal("TLS connection was not established")
+       }
+       defer conn.Close()
+       require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
+       // Wait until TLS handshake has started. The server deliberately never 
replies.
+       _, err = io.ReadFull(conn, make([]byte, 1))
+       require.NoError(t, err)
+       stopped := make(chan struct{})
+       go func() { client.ShutDown(); close(stopped) }()
+       awaitShutdownSignal(t, stopped)
+       awaitShutdownSignal(t, invoked)
+}
+
+func TestResponseCompletionRaces(t *testing.T) {
+       for i := 0; i < 100; i++ {
+               ctx, cancel := context.WithCancel(context.Background())
+               f := NewResponseFuture(ctx, int32(i), nil)
+               cmd := NewRemotingCommand(10, nil, nil)
+               var wg sync.WaitGroup
+               wg.Add(3)
+               go func() { defer wg.Done(); f.complete(cmd, nil) }()
+               go func() { defer wg.Done(); f.complete(nil, errClientClosed) 
}()
+               go func() { defer wg.Done(); cancel() }()
+               response, err := f.waitResponse()
+               wg.Wait()
+               require.True(t, response == cmd || err != nil, "completion lost 
both response and error")
+               require.Same(t, response, f.ResponseCommand)
+               require.Equal(t, err, f.Err)
+       }
+}
+
+func TestConcurrentRequestRegistrationAndShutdown(t *testing.T) {
+       for i := 0; i < 20; i++ {
+               client := NewRemotingClient(nil)
+               start := make(chan struct{})
+               var wg sync.WaitGroup
+               for j := 0; j < 20; j++ {
+                       wg.Add(1)
+                       go func(id int32) {
+                               defer wg.Done()
+                               <-start
+                               f := NewResponseFuture(context.Background(), 
id, nil)
+                               if client.registerResponse(f) == nil {
+                                       _, err := f.waitResponse()
+                                       if err != errClientClosed {
+                                               t.Error("registered request was 
not canceled")
+                                       }
+                               }
+                       }(int32(j))
+               }
+               close(start)
+               client.ShutDown()
+               exited := make(chan struct{})
+               go func() { wg.Wait(); close(exited) }()
+               awaitShutdownSignal(t, exited)
+       }
+}
+
+// The dial context covers setup only; canceling it must not close a usable 
TLS connection.
+func TestTLSConnectionSurvivesDialContextCancellation(t *testing.T) {
+       server := httptest.NewTLSServer(http.HandlerFunc(func(w 
http.ResponseWriter, r *http.Request) {
+               io.WriteString(w, "connected")
+       }))
+       defer server.Close()
+       config := DefaultRemotingClientConfig
+       config.UseTls = true
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+       conn, err := initConn(ctx, server.Listener.Addr().String(), &config)
+       require.NoError(t, err)
+       defer conn.destroy()
+       cancel()
+       require.NoError(t, conn.SetDeadline(time.Now().Add(2*time.Second)))
+       _, err = io.WriteString(conn, "GET / HTTP/1.1\r\nHost: 
localhost\r\nConnection: close\r\n\r\n")
+       require.NoError(t, err)
+       response, err := http.ReadResponse(bufio.NewReader(conn), nil)
+       require.NoError(t, err)
+       defer response.Body.Close()
+       body, err := ioutil.ReadAll(response.Body)
+       require.NoError(t, err)
+       require.Equal(t, "connected", string(body))
+}
diff --git a/internal/remote/tcp_conn.go b/internal/remote/tcp_conn.go
index 3b7d164..2954dc1 100644
--- a/internal/remote/tcp_conn.go
+++ b/internal/remote/tcp_conn.go
@@ -38,19 +38,47 @@ func initConn(ctx context.Context, addr string, config 
*RemotingClientConfig) (*
        d.KeepAlive = config.KeepAliveDuration
        d.Deadline = time.Now().Add(config.ConnectionTimeout)
 
-       var conn net.Conn
-       var err error
-       if config.UseTls {
-               conn, err = tls.DialWithDialer(&d, "tcp", addr, &tls.Config{
-                       InsecureSkipVerify: true,
-               })
-       } else {
-               conn, err = d.DialContext(ctx, "tcp", addr)
-       }
-
+       conn, err := d.DialContext(ctx, "tcp", addr)
        if err != nil {
                return nil, err
        }
+       if config.UseTls {
+               host, _, _ := net.SplitHostPort(addr)
+               tlsConn := tls.Client(conn, &tls.Config{ServerName: host, 
InsecureSkipVerify: true})
+               deadline := d.Deadline
+               if ctxDeadline, ok := ctx.Deadline(); ok && 
ctxDeadline.Before(deadline) {
+                       deadline = ctxDeadline
+               }
+               if err := conn.SetDeadline(deadline); err != nil {
+                       conn.Close()
+                       return nil, err
+               }
+               handshakeDone := make(chan struct{})
+               watcherDone := make(chan struct{})
+               go func() {
+                       defer close(watcherDone)
+                       select {
+                       case <-ctx.Done():
+                               conn.Close()
+                       case <-handshakeDone:
+                       }
+               }()
+               err = tlsConn.Handshake()
+               close(handshakeDone)
+               <-watcherDone
+               if err == nil {
+                       err = ctx.Err()
+               }
+               if err == nil {
+                       err = conn.SetDeadline(time.Time{})
+               }
+               if err != nil {
+                       conn.Close()
+                       return nil, err
+               }
+               conn = tlsConn
+       }
+
        return &tcpConnWrapper{
                Conn: conn,
        }, nil
diff --git a/internal/resource_lifecycle_test.go 
b/internal/resource_lifecycle_test.go
new file mode 100644
index 0000000..42b6fae
--- /dev/null
+++ b/internal/resource_lifecycle_test.go
@@ -0,0 +1,141 @@
+/*
+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 internal
+
+import (
+       "bytes"
+       "context"
+       "errors"
+       "github.com/golang/mock/gomock"
+       "io/ioutil"
+       "net"
+       "runtime/pprof"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/rocketmq-client-go/v2/internal/remote"
+       "github.com/apache/rocketmq-client-go/v2/primitive"
+       "github.com/stretchr/testify/require"
+)
+
+func TestLastClientReleaseClosesNameServerConnection(t *testing.T) {
+       listener, err := net.Listen("tcp", "127.0.0.1:0")
+       require.NoError(t, err)
+       defer listener.Close()
+       accepted := make(chan net.Conn, 1)
+       disconnected := make(chan struct{})
+       go func() {
+               defer close(disconnected)
+               conn, err := listener.Accept()
+               if err != nil {
+                       return
+               }
+               defer conn.Close()
+               accepted <- conn
+               ioutil.ReadAll(conn)
+       }()
+       srvs, err := 
NewNamesrv(primitive.NewPassthroughResolver([]string{listener.Addr().String()}),
 nil)
+       require.NoError(t, err)
+       options := DefaultClientOptions()
+       options.InstanceName = t.Name()
+       options.Namesrv = srvs
+       first := GetOrNewRocketMQClient(options, nil)
+       second := GetOrNewRocketMQClient(options, nil)
+       require.NotNil(t, first)
+       require.Same(t, first, second)
+       defer second.Shutdown()
+
+       ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+       defer cancel()
+       err = srvs.nameSrvClient.InvokeOneWay(ctx, listener.Addr().String(), 
remote.NewRemotingCommand(ReqGetRouteInfoByTopic, nil, nil))
+       require.NoError(t, err)
+       select {
+       case conn := <-accepted:
+               defer conn.Close()
+       case <-ctx.Done():
+               t.Fatal("no NameServer connection was opened")
+       }
+       first.Shutdown()
+       // A remaining owner must still be able to use the connection.
+       err = srvs.nameSrvClient.InvokeOneWay(ctx, listener.Addr().String(), 
remote.NewRemotingCommand(ReqGetRouteInfoByTopic, nil, nil))
+       require.NoError(t, err)
+       second.Shutdown()
+       select {
+       case <-disconnected:
+       case <-time.After(time.Second):
+               t.Fatal("NameServer connection remained open after the last 
release")
+       }
+       // Late refresh work must not recreate the disposed transport's 
connection.
+       err = srvs.nameSrvClient.InvokeOneWay(ctx, listener.Addr().String(), 
remote.NewRemotingCommand(ReqGetRouteInfoByTopic, nil, nil))
+       require.Error(t, err)
+}
+
+func TestConcurrentShutdownAndInvoke(t *testing.T) {
+       ctrl := gomock.NewController(t)
+       defer ctrl.Finish()
+       r := remote.NewMockRemotingClient(ctrl)
+       r.EXPECT().InvokeSync(gomock.Any(), gomock.Any(), 
gomock.Any()).Return(nil, errors.New("test transport")).AnyTimes()
+       r.EXPECT().ShutDown()
+       opts := DefaultClientOptions()
+       opts.InstanceName = t.Name()
+       srvs, err := 
NewNamesrv(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"}), nil)
+       require.NoError(t, err)
+       opts.Namesrv = srvs
+       c := GetOrNewRocketMQClient(opts, nil).(*rmqClient)
+       c.remoteClient = r
+       started, stop, stopped := make(chan struct{}), make(chan struct{}), 
make(chan struct{})
+       go func() {
+               defer close(stopped)
+               close(started)
+               for {
+                       select {
+                       case <-stop:
+                               return
+                       default:
+                               c.InvokeSync(context.Background(), "unused", 
remote.NewRemotingCommand(10, nil, nil), time.Second)
+                       }
+               }
+       }()
+       <-started
+       time.Sleep(time.Millisecond)
+       c.Shutdown()
+       close(stop)
+       <-stopped
+}
+
+func clientBackgroundWorkers() int {
+       var b bytes.Buffer
+       pprof.Lookup("goroutine").WriteTo(&b, 2)
+       return strings.Count(b.String(), "(*rmqClient).Start.func")
+}
+
+func TestClientRepeatedLifecycleStopsBackgroundWorkers(t *testing.T) {
+       baseline := clientBackgroundWorkers()
+       for i := 0; i < 10; i++ {
+               srvs, err := 
NewNamesrv(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"}), nil)
+               require.NoError(t, err)
+               opts := DefaultClientOptions()
+               opts.InstanceName = t.Name()
+               opts.Namesrv = srvs
+               c := GetOrNewRocketMQClient(opts, nil)
+               c.Start()
+               c.Shutdown()
+               require.Eventually(t, func() bool { return 
clientBackgroundWorkers() <= baseline }, time.Second, time.Millisecond, 
"shutdown left discovery/heartbeat workers waiting")
+       }
+}
diff --git a/internal/utils/wait.go b/internal/utils/wait.go
new file mode 100644
index 0000000..233f3aa
--- /dev/null
+++ b/internal/utils/wait.go
@@ -0,0 +1,43 @@
+/*
+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 utils
+
+import "time"
+
+// WaitFor waits for a duration, returning false when shutdown interrupts it.
+func WaitFor(done <-chan struct{}, delay time.Duration) bool {
+       select {
+       case <-done:
+               return false
+       default:
+       }
+       timer := time.NewTimer(delay)
+       defer timer.Stop()
+       select {
+       case <-done:
+               return false
+       case <-timer.C:
+               // Prefer shutdown if both events became ready together.
+               select {
+               case <-done:
+                       return false
+               default:
+                       return true
+               }
+       }
+}
diff --git a/producer/producer.go b/producer/producer.go
index 59dc182..e490f74 100644
--- a/producer/producer.go
+++ b/producer/producer.go
@@ -44,11 +44,13 @@ type defaultProducer struct {
        options     producerOptions
        publishInfo sync.Map
        callbackCh  chan interface{}
+       done        chan struct{}
 
        interceptor primitive.Interceptor
 
        lifecycleMu  sync.Mutex
        startOnce    sync.Once
+       startErr     error
        ShutdownOnce sync.Once
 }
 
@@ -75,9 +77,10 @@ func NewDefaultProducer(opts ...Option) (*defaultProducer, 
error) {
        producer := &defaultProducer{
                group:      defaultOpts.GroupName,
                callbackCh: make(chan interface{}),
+               done:       make(chan struct{}),
                options:    defaultOpts,
        }
-       producer.client = 
internal.GetOrNewRocketMQClient(defaultOpts.ClientOptions, producer.callbackCh)
+       producer.client = 
internal.GetOrNewRocketMQClient(defaultOpts.ClientOptions, producer.callbackCh, 
producer.done)
        if producer.client == nil {
                return nil, fmt.Errorf("GetOrNewRocketMQClient faild")
        }
@@ -95,31 +98,33 @@ func (p *defaultProducer) Start() error {
        if atomic.LoadInt32(&p.state) == int32(internal.StateShutdown) {
                return internal.ErrServiceState
        }
-       var err error
        defer func() {
-               if err != nil && 
!internal.IsNilTraceDispatcher(p.options.TraceDispatcher) {
+               if p.startErr != nil && 
!internal.IsNilTraceDispatcher(p.options.TraceDispatcher) {
                        p.options.TraceDispatcher.Close()
                }
        }()
        p.startOnce.Do(func() {
-               err = p.client.RegisterProducer(p.group, p)
-               if err != nil {
+               p.startErr = p.client.RegisterProducer(p.group, p)
+               if p.startErr != nil {
                        rlog.Error("the producer group has been created, 
specify another one", map[string]interface{}{
                                rlog.LogKeyProducerGroup: p.group,
                        })
-                       err = errors2.ErrProducerCreated
+                       p.startErr = errors2.ErrProducerCreated
                        return
                }
                p.client.Start()
                atomic.StoreInt32(&p.state, int32(internal.StateRunning))
        })
-       return err
+       return p.startErr
 }
 
 func (p *defaultProducer) Shutdown() error {
        p.lifecycleMu.Lock()
        defer p.lifecycleMu.Unlock()
        p.ShutdownOnce.Do(func() {
+               if p.done != nil {
+                       close(p.done)
+               }
                if !internal.IsNilTraceDispatcher(p.options.TraceDispatcher) {
                        p.options.TraceDispatcher.Close()
                }
@@ -657,8 +662,9 @@ func (p *defaultProducer) IsUnitMode() bool {
 }
 
 type transactionProducer struct {
-       producer *defaultProducer
-       listener primitive.TransactionListener
+       producer  *defaultProducer
+       listener  primitive.TransactionListener
+       checkOnce sync.Once
 }
 
 // TODO: checkLocalTransaction
@@ -674,10 +680,15 @@ func NewTransactionProducer(listener 
primitive.TransactionListener, opts ...Opti
 }
 
 func (tp *transactionProducer) Start() error {
-       go primitive.WithRecover(func() {
-               tp.checkTransactionState()
+       if err := tp.producer.Start(); err != nil {
+               return err
+       }
+       tp.checkOnce.Do(func() {
+               go primitive.WithRecover(func() {
+                       tp.checkTransactionState()
+               })
        })
-       return tp.producer.Start()
+       return nil
 }
 func (tp *transactionProducer) Shutdown() error {
        return tp.producer.Shutdown()
@@ -685,7 +696,20 @@ func (tp *transactionProducer) Shutdown() error {
 
 // TODO: check addr
 func (tp *transactionProducer) checkTransactionState() {
-       for ch := range tp.producer.callbackCh {
+       for {
+               var ch interface{}
+               select {
+               case <-tp.producer.done:
+                       return
+               case ch = <-tp.producer.callbackCh:
+               }
+               // A callback can arrive at the same time as shutdown. Do not 
begin new
+               // application work once the producer has stopped.
+               select {
+               case <-tp.producer.done:
+                       return
+               default:
+               }
                switch callback := ch.(type) {
                case *internal.CheckTransactionStateCallback:
                        localTransactionState := 
tp.listener.CheckLocalTransaction(callback.Msg)
diff --git a/producer/resource_lifecycle_test.go 
b/producer/resource_lifecycle_test.go
new file mode 100644
index 0000000..222e7d4
--- /dev/null
+++ b/producer/resource_lifecycle_test.go
@@ -0,0 +1,86 @@
+/*
+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 producer
+
+import (
+       "bytes"
+       "runtime/pprof"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/rocketmq-client-go/v2/primitive"
+       "github.com/stretchr/testify/require"
+)
+
+func TestTransactionWorkerStopsOnShutdown(t *testing.T) {
+       // Keep another owner alive: stopping this worker must not require 
disposing
+       // the shared RMQ client used by the remaining producer.
+       other, err := NewDefaultProducer(WithInstanceName(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer other.Shutdown()
+
+       tp, err := NewTransactionProducer(nil,
+               WithInstanceName(t.Name()),
+               
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"})))
+       require.NoError(t, err)
+       defer tp.Shutdown()
+
+       // Observe the actual worker's exit without relying on process-wide
+       // goroutine counts or sleeping until an unrelated SDK worker exits.
+       exited := make(chan struct{})
+       go func() {
+               defer close(exited)
+               tp.checkTransactionState()
+       }()
+       require.NoError(t, tp.Shutdown())
+       select {
+       case <-exited:
+       case <-time.After(time.Second):
+               t.Fatal("transaction worker remained blocked after shutdown")
+       }
+       require.Error(t, tp.Start())
+}
+
+func transactionWorkerCount() int {
+       var b bytes.Buffer
+       pprof.Lookup("goroutine").WriteTo(&b, 2)
+       return strings.Count(b.String(), 
"(*transactionProducer).checkTransactionState(")
+}
+
+func TestTransactionRepeatedLifecycle(t *testing.T) {
+       baseline := transactionWorkerCount()
+       for i := 0; i < 10; i++ {
+               opts := []Option{WithInstanceName(t.Name()), 
WithGroupName(t.Name()), 
WithNsResolver(primitive.NewPassthroughResolver([]string{"127.0.0.1:9876"}))}
+               p, err := NewTransactionProducer(nil, opts...)
+               require.NoError(t, err)
+               defer p.Shutdown()
+               for j := 0; j < 3; j++ {
+                       require.NoError(t, p.Start())
+               }
+               require.Eventually(t, func() bool { return 
transactionWorkerCount() == baseline+1 }, time.Second, time.Millisecond)
+               failed, err := NewTransactionProducer(nil, opts...)
+               require.NoError(t, err)
+               defer failed.Shutdown()
+               require.Error(t, failed.Start())
+               require.Error(t, failed.Start(), "repeated failed Start must 
not launch a transaction worker")
+               require.NoError(t, failed.Shutdown())
+               require.NoError(t, p.Shutdown())
+               require.Eventually(t, func() bool { return 
transactionWorkerCount() <= baseline }, time.Second, time.Millisecond)
+       }
+}

Reply via email to