This is an automated email from the ASF dual-hosted git repository.
AlexStocks 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 4879a3c41 fix(remoting): clean up pendingResponses on error paths to
prevent map leak (#3440)
4879a3c41 is described below
commit 4879a3c4111cd30f60116baa9607aafc6daabf42
Author: aias00 <[email protected]>
AuthorDate: Sat Jul 11 14:55:48 2026 +0800
fix(remoting): clean up pendingResponses on error paths to prevent map leak
(#3440)
* fix(remoting): clean up pendingResponses on error paths to prevent map
leak
The global pendingResponses sync.Map is cleaned up on the success path
via Response.Handle() -> removePendingResponse(), but entries were never
removed when requests fail:
- ExchangeClient.Request(): AddPendingResponse called before
client.Request(), but on error the entry was left in the map
- ExchangeClient.AsyncRequest(): same pattern, same leak
- getty heartbeat(): timeout branch never called removePendingResponse,
leaving the entry to accumulate indefinitely
Add removePendingResponse calls in all three error/timeout paths. Export
RemovePendingResponse so the getty package (external to remoting) can
call it from its heartbeat timeout handler.
* refactor(remoting): export removePendingResponse directly instead of
wrapper
Address review feedback: the extra RemovePendingResponse wrapper added no
value and was inconsistent with its siblings AddPendingResponse/
GetPendingResponse, which are exported directly. Rename
removePendingResponse
to the exported RemovePendingResponse and update all in-package call sites.
* test(remoting): add regression tests for pendingResponses cleanup on
error paths
Cover the map-leak fix with tests that fail if the cleanup is removed:
- ExchangeClient Request/AsyncRequest remove the pending response when the
underlying client.Request returns an error
- getty heartbeat removes the pending response on read timeout
* test(remoting): use any instead of interface{} in heartbeat test mock
---
remoting/exchange.go | 6 ++--
remoting/exchange_client.go | 2 ++
remoting/exchange_client_test.go | 58 +++++++++++++++++++++++++++++-
remoting/exchange_test.go | 4 +--
remoting/getty/heartbeat_test.go | 76 ++++++++++++++++++++++++++++++++++++++++
remoting/getty/listener.go | 1 +
6 files changed, 141 insertions(+), 6 deletions(-)
diff --git a/remoting/exchange.go b/remoting/exchange.go
index 60dd11b47..ccfcd8c31 100644
--- a/remoting/exchange.go
+++ b/remoting/exchange.go
@@ -93,7 +93,7 @@ func (response *Response) IsHeartbeat() bool {
}
func (response *Response) Handle() {
- pendingResponse := removePendingResponse(SequenceType(response.ID))
+ pendingResponse := RemovePendingResponse(SequenceType(response.ID))
if pendingResponse == nil {
logger.Errorf("[Remoting] failed to get pending response
context for response package %s", *response)
return
@@ -172,8 +172,8 @@ func AddPendingResponse(pr *PendingResponse) {
pendingResponses.Store(SequenceType(pr.seq), pr)
}
-// get and remove response
-func removePendingResponse(seq SequenceType) *PendingResponse {
+// RemovePendingResponse gets and removes the pending response for the given
sequence ID.
+func RemovePendingResponse(seq SequenceType) *PendingResponse {
if pendingResponses == nil {
return nil
}
diff --git a/remoting/exchange_client.go b/remoting/exchange_client.go
index a7cb53cf2..6de4833e3 100644
--- a/remoting/exchange_client.go
+++ b/remoting/exchange_client.go
@@ -132,6 +132,7 @@ func (client *ExchangeClient) Request(invocation
*base.Invocation, url *common.U
err := client.client.Request(request, timeout, rsp)
// request error
if err != nil {
+ RemovePendingResponse(SequenceType(request.ID))
res.Err = err
return err
}
@@ -165,6 +166,7 @@ func (client *ExchangeClient) AsyncRequest(invocation
*base.Invocation, url *com
err := client.client.Request(request, timeout, rsp)
if err != nil {
+ RemovePendingResponse(SequenceType(request.ID))
result.Err = err
return err
}
diff --git a/remoting/exchange_client_test.go b/remoting/exchange_client_test.go
index 08245e9ab..4a829c5d9 100644
--- a/remoting/exchange_client_test.go
+++ b/remoting/exchange_client_test.go
@@ -26,10 +26,14 @@ import (
import (
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
import (
"dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/protocol/base"
+ "dubbo.apache.org/dubbo-go/v3/protocol/invocation"
+ "dubbo.apache.org/dubbo-go/v3/protocol/result"
)
type mockClient struct {
@@ -37,6 +41,7 @@ type mockClient struct {
available bool
connectErr error
connCount int
+ requestErr error
}
func (m *mockClient) SetExchangeClient(client *ExchangeClient) {}
@@ -44,7 +49,9 @@ func (m *mockClient) Close()
{}
func (m *mockClient) IsAvailable() bool { m.mu.Lock();
defer m.mu.Unlock(); return m.available }
func (m *mockClient) Request(request *Request, timeout time.Duration, response
*PendingResponse) error {
- return nil
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.requestErr
}
func (m *mockClient) Connect(url *common.URL) error {
@@ -104,3 +111,52 @@ func TestExchangeClientIsAvailable(t *testing.T) {
m.mu.Unlock()
assert.False(t, ec.IsAvailable())
}
+
+// newTestInvocation builds a *base.Invocation usable by
ExchangeClient.Request/AsyncRequest.
+func newTestInvocation() *base.Invocation {
+ var inv base.Invocation = invocation.NewRPCInvocation("test", nil, nil)
+ return &inv
+}
+
+// TestExchangeClientRequestErrorCleanup is a regression test for the map leak
fix:
+// when the underlying client.Request returns an error, the pending response
for the
+// request ID must be removed from pendingResponses so the global map does not
leak.
+func TestExchangeClientRequestErrorCleanup(t *testing.T) {
+ m := &mockClient{available: true, requestErr: errors.New("request
failed")}
+ ec := NewExchangeClient(testURL(), m, 5*time.Second, true)
+
+ before := countPendingResponses()
+ res := &result.RPCResult{}
+ err := ec.Request(newTestInvocation(), testURL(), time.Second, res)
+
+ require.Error(t, err)
+ assert.Equal(t, err, res.Err)
+ // No pending response should be left behind on the error path.
+ assert.Equal(t, before, countPendingResponses(), "pendingResponses
leaked on Request error path")
+}
+
+// TestExchangeClientAsyncRequestErrorCleanup mirrors the sync case for
AsyncRequest.
+func TestExchangeClientAsyncRequestErrorCleanup(t *testing.T) {
+ m := &mockClient{available: true, requestErr: errors.New("request
failed")}
+ ec := NewExchangeClient(testURL(), m, 5*time.Second, true)
+
+ before := countPendingResponses()
+ res := &result.RPCResult{}
+ cb := func(response common.CallbackResponse) {}
+ err := ec.AsyncRequest(newTestInvocation(), testURL(), time.Second, cb,
res)
+
+ require.Error(t, err)
+ assert.Equal(t, err, res.Err)
+ assert.Equal(t, before, countPendingResponses(), "pendingResponses
leaked on AsyncRequest error path")
+}
+
+// countPendingResponses returns the number of entries currently held in the
global
+// pendingResponses map.
+func countPendingResponses() int {
+ n := 0
+ pendingResponses.Range(func(_, _ any) bool {
+ n++
+ return true
+ })
+ return n
+}
diff --git a/remoting/exchange_test.go b/remoting/exchange_test.go
index 92d7b6746..e12e0e8ca 100644
--- a/remoting/exchange_test.go
+++ b/remoting/exchange_test.go
@@ -106,8 +106,8 @@ func TestAddGetRemovePendingResponse(t *testing.T) {
pr := NewPendingResponse(999)
AddPendingResponse(pr)
assert.Equal(t, pr, GetPendingResponse(SequenceType(999)))
- assert.Equal(t, pr, removePendingResponse(SequenceType(999)))
- assert.Nil(t, removePendingResponse(SequenceType(999)))
+ assert.Equal(t, pr, RemovePendingResponse(SequenceType(999)))
+ assert.Nil(t, RemovePendingResponse(SequenceType(999)))
}
func TestResponseHandle(t *testing.T) {
diff --git a/remoting/getty/heartbeat_test.go b/remoting/getty/heartbeat_test.go
new file mode 100644
index 000000000..6e76b3e83
--- /dev/null
+++ b/remoting/getty/heartbeat_test.go
@@ -0,0 +1,76 @@
+/*
+ * 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 getty
+
+import (
+ "testing"
+ "time"
+)
+
+import (
+ getty "github.com/apache/dubbo-getty"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/remoting"
+)
+
+// mockHeartbeatSession embeds getty.Session so it satisfies the interface, and
+// overrides only WritePkg. It captures the ID of the heartbeat request that
was
+// written so the test can assert the pending response is cleaned up
afterwards.
+type mockHeartbeatSession struct {
+ getty.Session
+ writtenID int64
+}
+
+func (m *mockHeartbeatSession) WritePkg(pkg any, _ time.Duration) (int, int,
error) {
+ if req, ok := pkg.(*remoting.Request); ok {
+ m.writtenID = req.ID
+ }
+ // Return sendLen == 0 so heartbeat does not try to close the session.
+ return 0, 0, nil
+}
+
+// TestHeartbeatTimeoutCleanup is a regression test for the map leak fix: when
a
+// heartbeat times out (no response arrives), the pending response registered
for
+// the heartbeat request ID must be removed from remoting's pendingResponses
so the
+// global map does not leak.
+func TestHeartbeatTimeoutCleanup(t *testing.T) {
+ sess := &mockHeartbeatSession{}
+ done := make(chan error, 1)
+
+ err := heartbeat(sess, 10*time.Millisecond, func(err error) { done <-
err })
+ require.NoError(t, err)
+ require.NotZero(t, sess.writtenID, "heartbeat request should have been
written")
+
+ select {
+ case cbErr := <-done:
+ // The timeout branch is what performs the cleanup.
+ assert.Equal(t, errHeartbeatReadTimeout, cbErr)
+ case <-time.After(2 * time.Second):
+ t.Fatal("heartbeat callback was not invoked")
+ }
+
+ // After the timeout branch runs (which happens before the callback),
the pending
+ // response for the heartbeat request must have been removed.
+ assert.Nil(t,
remoting.GetPendingResponse(remoting.SequenceType(sess.writtenID)),
+ "pendingResponses leaked on heartbeat timeout path")
+}
diff --git a/remoting/getty/listener.go b/remoting/getty/listener.go
index ed3a69114..4964463aa 100644
--- a/remoting/getty/listener.go
+++ b/remoting/getty/listener.go
@@ -354,6 +354,7 @@ func heartbeat(session getty.Session, timeout
time.Duration, callBack func(err e
select {
case <-gxtime.After(timeout):
err1 = errHeartbeatReadTimeout
+
remoting.RemovePendingResponse(remoting.SequenceType(req.ID))
case <-resp.Done:
err1 = resp.Err
}