You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@seata.apache.org by wa...@apache.org on 2026/10/11 09:39:58 UTC

(incubator-seata) branch 2.x updated: bugfix: fix TC memory leak from retained client channels

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

WangzJi pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/incubator-seata.git


The following commit(s) were added to refs/heads/2.x by this push:
     new 8a31b1f41d bugfix: fix TC memory leak from retained client channels
8a31b1f41d is described below

commit 8a31b1f41da325f5b498d3fa2853221620388d35
Author: alex-ashuo <sa...@hotmail.com>
AuthorDate: Sun Oct 11 17:39:53 2026 +0800

    bugfix: fix TC memory leak from retained client channels
---
 changes/en-us/2.x.md                               |   3 +
 changes/zh-cn/2.x.md                               |   3 +
 .../rpc/netty/AbstractNettyRemotingServer.java     |  40 ++-
 .../seata/core/rpc/netty/ChannelManager.java       |  24 ++
 .../core/rpc/processor/server/RegRmProcessor.java  |  10 +-
 .../processor/server/ServerOnRequestProcessor.java |   9 +-
 .../core/rpc/netty/ChannelManagerOOMTest.java      | 341 +++++++++++++++++++++
 .../server/ServerOnRequestProcessorTest.java       |  31 ++
 8 files changed, 455 insertions(+), 6 deletions(-)

diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index 2dc7b9a38b..23668b3d5e 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -45,6 +45,7 @@ Add changes here for all PR submitted to the 2.x branch.
 - [[#8182](https://github.com/apache/incubator-seata/issues/8182)] fix registry preferred networks and ignored interfaces from Spring Boot application.yml
 - [[#8201](https://github.com/apache/incubator-seata/issues/8197)] fix residual file-mode global locks caused by an async commit cleanup race
 - [[#8276](https://github.com/apache/incubator-seata/pull/8276)] fix Redis global session query returning a non-matching XID row when a status filter is empty
+- [[#8224](https://github.com/apache/incubator-seata/pull/8224)] remove closed client channels from the TC identified channels after a grace period to fix TC FullGC and OOM
 
 
 
@@ -108,6 +109,8 @@ Thanks to these contributors for their code commits. Please report an unintended
 - [pyshiweijia](https://github.com/pyshiweijia)
 - [biedongbin](https://github.com/biedongbin)
 - [Zhengcy05](https://github.com/Zhengcy05)
+- [saul-c](https://github.com/saul-c)
+- [YongGoose](https://github.com/YongGoose)
 
 
 
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index ccd7dc4aaa..b2473c2061 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -45,6 +45,7 @@
 - [[#8182](https://github.com/apache/incubator-seata/issues/8182)] 修复 Spring Boot application.yml 中 registry preferred networks 和 ignored interfaces 配置不生效的问题
 - [[#8201](https://github.com/apache/incubator-seata/issues/8197)] 修复异步提交清理竞态导致 file 模式全局锁残留的问题
 - [[#8276](https://github.com/apache/incubator-seata/pull/8276)] 修复 Redis 全局会话按 XID 加状态查询时,状态不匹配仍返回该会话的问题
+- [[#8224](https://github.com/apache/incubator-seata/pull/8224)] 客户端连接关闭后延迟清理 TC 中的已识别通道,修复其持续堆积导致的 FullGC 与 OOM 问题
 
 
 ### optimize:
@@ -108,6 +109,8 @@
 - [pyshiweijia](https://github.com/pyshiweijia)
 - [biedongbin](https://github.com/biedongbin)
 - [Zhengcy05](https://github.com/Zhengcy05)
+- [saul-c](https://github.com/saul-c)
+- [YongGoose](https://github.com/YongGoose)
 
 
 
diff --git a/core/src/main/java/org/apache/seata/core/rpc/netty/AbstractNettyRemotingServer.java b/core/src/main/java/org/apache/seata/core/rpc/netty/AbstractNettyRemotingServer.java
index 1bcad8c6f7..8cdefebf37 100644
--- a/core/src/main/java/org/apache/seata/core/rpc/netty/AbstractNettyRemotingServer.java
+++ b/core/src/main/java/org/apache/seata/core/rpc/netty/AbstractNettyRemotingServer.java
@@ -24,6 +24,7 @@ import io.netty.channel.ChannelPromise;
 import io.netty.handler.codec.DecoderException;
 import io.netty.handler.timeout.IdleState;
 import io.netty.handler.timeout.IdleStateEvent;
+import org.apache.seata.common.DefaultValues;
 import org.apache.seata.common.util.NetUtil;
 import org.apache.seata.common.util.StringUtils;
 import org.apache.seata.core.protocol.AbstractMessage;
@@ -41,7 +42,9 @@ import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
 /**
@@ -53,6 +56,13 @@ public abstract class AbstractNettyRemotingServer extends AbstractNettyRemoting
 
     private static final Logger LOGGER = LoggerFactory.getLogger(AbstractNettyRemotingServer.class);
 
+    /**
+     * How long a closed channel stays identified, so requests and responses still in flight for it keep working;
+     * a TM response, for one, can fall back to another channel of the same client. Matches the default TM request
+     * timeout, after which the client no longer waits.
+     */
+    private static final long CLOSED_CHANNEL_RETENTION_MILLS = DefaultValues.DEFAULT_RPC_TM_REQUEST_TIMEOUT;
+
     private final NettyServerBootstrap serverBootstrap;
 
     @Override
@@ -136,6 +146,26 @@ public abstract class AbstractNettyRemotingServer extends AbstractNettyRemoting
         super.destroy();
     }
 
+    /**
+     * Remove a closed channel from the identified channels once the retention time has passed.
+     * Until then, requests already received from it are still handled and a TM response can fall back to another
+     * channel of the same client.
+     *
+     * @param channel    the closed channel
+     * @param rpcContext the context the channel was bound to
+     */
+    protected void removeIdentifiedChannelLater(Channel channel, RpcContext rpcContext) {
+        try {
+            timerExecutor.schedule(
+                    () -> ChannelManager.removeIdentifiedChannel(channel, rpcContext),
+                    CLOSED_CHANNEL_RETENTION_MILLS,
+                    TimeUnit.MILLISECONDS);
+        } catch (RejectedExecutionException e) {
+            // the server is shutting down, nothing routes to this channel anymore
+            ChannelManager.removeIdentifiedChannel(channel, rpcContext);
+        }
+    }
+
     /**
      * Debug log.
      *
@@ -203,7 +233,14 @@ public abstract class AbstractNettyRemotingServer extends AbstractNettyRemoting
             if (messageExecutor.isShutdown()) {
                 return;
             }
-            handleDisconnect(ctx);
+            RpcContext rpcContext = ChannelManager.getContextFromIdentified(ctx.channel());
+            try {
+                handleDisconnect(ctx);
+            } finally {
+                if (rpcContext != null) {
+                    removeIdentifiedChannelLater(ctx.channel(), rpcContext);
+                }
+            }
             super.channelInactive(ctx);
         }
 
@@ -288,6 +325,7 @@ public abstract class AbstractNettyRemotingServer extends AbstractNettyRemoting
         // If the client is not version 2.3.0 or higher, splitting MergedWarpMessage will result in the client’s
         // mergeMsgMap not being cleared
         if (body instanceof MergedWarpMessage
+                && rpcContext != null
                 && (StringUtils.isNotBlank(rpcContext.getVersion())
                         && Version.isAboveOrEqualVersion230(rpcContext.getVersion()))) {
             MergedWarpMessage mergedWarpMessage = (MergedWarpMessage) body;
diff --git a/core/src/main/java/org/apache/seata/core/rpc/netty/ChannelManager.java b/core/src/main/java/org/apache/seata/core/rpc/netty/ChannelManager.java
index 4fc6d04249..b66025ca93 100644
--- a/core/src/main/java/org/apache/seata/core/rpc/netty/ChannelManager.java
+++ b/core/src/main/java/org/apache/seata/core/rpc/netty/ChannelManager.java
@@ -290,6 +290,30 @@ public class ChannelManager {
         }
     }
 
+    /**
+     * Remove a closed channel from the identified channels, only if it is still bound to the given context.
+     *
+     * @param channel    the closed channel
+     * @param rpcContext the context the channel was bound to when it was closed
+     */
+    static void removeIdentifiedChannel(Channel channel, RpcContext rpcContext) {
+        IDENTIFIED_CHANNELS.remove(channel, rpcContext);
+    }
+
+    /**
+     * Release a closed channel and remove it from the identified channels at once. Used when the channel was
+     * registered after it had closed, so the disconnect handling found nothing to clean up.
+     *
+     * @param channel the closed channel
+     */
+    public static void removeClosedChannel(Channel channel) {
+        RpcContext rpcContext = getContextFromIdentified(channel);
+        if (rpcContext != null) {
+            rpcContext.release();
+            removeIdentifiedChannel(channel, rpcContext);
+        }
+    }
+
     /**
      * Gets get same income client channel.
      *
diff --git a/core/src/main/java/org/apache/seata/core/rpc/processor/server/RegRmProcessor.java b/core/src/main/java/org/apache/seata/core/rpc/processor/server/RegRmProcessor.java
index 88578f446a..e43b164cac 100644
--- a/core/src/main/java/org/apache/seata/core/rpc/processor/server/RegRmProcessor.java
+++ b/core/src/main/java/org/apache/seata/core/rpc/processor/server/RegRmProcessor.java
@@ -93,7 +93,15 @@ public class RegRmProcessor implements RemotingProcessor {
         if (StringUtils.isNotEmpty(errorInfo)) {
             response.setMsg(errorInfo);
         }
-        remotingServer.sendAsyncResponse(rpcMessage, ctx.channel(), response);
+        try {
+            remotingServer.sendAsyncResponse(rpcMessage, ctx.channel(), response);
+        } finally {
+            // the channel may have closed while this request waited in the executor, and then the disconnect
+            // handling had nothing to clean up yet
+            if (!ctx.channel().isActive()) {
+                ChannelManager.removeClosedChannel(ctx.channel());
+            }
+        }
         if (isSuccess && LOGGER.isInfoEnabled()) {
             LOGGER.info(
                     "RM register success,message:{},channel:{},client version:{}",
diff --git a/core/src/main/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessor.java b/core/src/main/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessor.java
index 367120ec1b..1696d665d9 100644
--- a/core/src/main/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessor.java
+++ b/core/src/main/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessor.java
@@ -120,8 +120,10 @@ public class ServerOnRequestProcessor implements RemotingProcessor, Disposable {
 
     @Override
     public void process(ChannelHandlerContext ctx, RpcMessage rpcMessage) throws Exception {
-        if (ChannelManager.isRegistered(ctx.channel())) {
-            onRequestMessage(ctx, rpcMessage);
+        // look the context up only once, the IO thread may release the channel at any time
+        RpcContext rpcContext = ChannelManager.getContextFromIdentified(ctx.channel());
+        if (rpcContext != null) {
+            onRequestMessage(ctx, rpcMessage, rpcContext);
         } else {
             try {
                 if (LOGGER.isInfoEnabled()) {
@@ -146,9 +148,8 @@ public class ServerOnRequestProcessor implements RemotingProcessor, Disposable {
         }
     }
 
-    private void onRequestMessage(ChannelHandlerContext ctx, RpcMessage rpcMessage) {
+    private void onRequestMessage(ChannelHandlerContext ctx, RpcMessage rpcMessage, RpcContext rpcContext) {
         Object message = rpcMessage.getBody();
-        RpcContext rpcContext = ChannelManager.getContextFromIdentified(ctx.channel());
         if (!(message instanceof AbstractMessage)) {
             LOGGER.error("unrecognized message:{}", message);
             return;
diff --git a/core/src/test/java/org/apache/seata/core/rpc/netty/ChannelManagerOOMTest.java b/core/src/test/java/org/apache/seata/core/rpc/netty/ChannelManagerOOMTest.java
new file mode 100644
index 0000000000..ce140cd7a3
--- /dev/null
+++ b/core/src/test/java/org/apache/seata/core/rpc/netty/ChannelManagerOOMTest.java
@@ -0,0 +1,341 @@
+/*
+ * 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 org.apache.seata.core.rpc.netty;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelPromise;
+import io.netty.handler.codec.DecoderException;
+import org.apache.seata.common.thread.NamedThreadFactory;
+import org.apache.seata.core.protocol.AbstractMessage;
+import org.apache.seata.core.protocol.MergedWarpMessage;
+import org.apache.seata.core.protocol.MessageType;
+import org.apache.seata.core.protocol.RegisterRMRequest;
+import org.apache.seata.core.protocol.RegisterTMRequest;
+import org.apache.seata.core.protocol.RpcMessage;
+import org.apache.seata.core.protocol.transaction.BranchRegisterRequest;
+import org.apache.seata.core.protocol.transaction.GlobalBeginRequest;
+import org.apache.seata.core.protocol.transaction.GlobalBeginResponse;
+import org.apache.seata.core.protocol.transaction.GlobalCommitResponse;
+import org.apache.seata.core.rpc.RemotingServer;
+import org.apache.seata.core.rpc.RpcContext;
+import org.apache.seata.core.rpc.TransactionMessageHandler;
+import org.apache.seata.core.rpc.processor.RemotingProcessor;
+import org.apache.seata.core.rpc.processor.server.RegRmProcessor;
+import org.apache.seata.core.rpc.processor.server.ServerOnRequestProcessor;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.notNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Closed client channels must leave the identified channels of {@link ChannelManager}, without breaking the
+ * requests and responses that are still in flight for them.
+ */
+class ChannelManagerOOMTest {
+
+    private static final String APP_ID = "business-app";
+    private static final String TX_GROUP = "default_tx_group";
+    private static final String RESOURCE_ID = "jdbc:mysql://127.0.0.1:3306/seata";
+
+    private ThreadPoolExecutor messageExecutor;
+    private TestServer server;
+    private AbstractNettyRemotingServer.ServerHandler handler;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        clearChannelMaps();
+        messageExecutor = new ThreadPoolExecutor(
+                1, 1, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new NamedThreadFactory("oom-test", 1));
+        server = new TestServer(messageExecutor);
+        handler = server.new ServerHandler();
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        try {
+            server.destroy();
+        } catch (Exception e) {
+            // Ignore
+        }
+        messageExecutor.shutdown();
+        clearChannelMaps();
+    }
+
+    @Test
+    void repeatedReconnectsDoNotAccumulateClosedChannels() throws Exception {
+        for (int i = 0; i < 200; i++) {
+            Channel tmChannel = channel(20000 + i, false);
+            ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), tmChannel);
+            handler.channelInactive(ctx(tmChannel));
+
+            Channel rmChannel = channel(30000 + i, false);
+            ChannelManager.registerRMChannel(rmRequest(), rmChannel);
+            handler.channelInactive(ctx(rmChannel));
+        }
+
+        server.elapseRetention();
+
+        assertTrue(channelMap("IDENTIFIED_CHANNELS").isEmpty());
+        assertEquals(0, contextCount(channelMap("TM_CHANNELS")));
+        assertEquals(0, contextCount(channelMap("RM_CHANNELS")));
+    }
+
+    @Test
+    void rmRegistrationHandledAfterCloseDoesNotLeak() throws Exception {
+        Channel channel = channel(40010, false);
+        ChannelHandlerContext ctx = ctx(channel);
+
+        // the channel closes while its RegisterRM request still waits in the executor
+        handler.channelInactive(ctx);
+        new RegRmProcessor(mock(RemotingServer.class)).process(ctx, registerRequest(rmRequest()));
+
+        assertFalse(ChannelManager.isRegistered(channel));
+        assertEquals(0, contextCount(channelMap("RM_CHANNELS")));
+    }
+
+    @Test
+    void rmRegistrationDuringRetentionDoesNotRestoreRouting() throws Exception {
+        Channel channel = channel(40011, false);
+        ChannelManager.registerRMChannel(rmRequest(), channel);
+        ChannelHandlerContext ctx = ctx(channel);
+        RegisterRMRequest anotherResource = new RegisterRMRequest(APP_ID, TX_GROUP);
+        anotherResource.setResourceIds("jdbc:mysql://127.0.0.1:3306/another");
+
+        handler.channelInactive(ctx);
+        new RegRmProcessor(mock(RemotingServer.class)).process(ctx, registerRequest(anotherResource));
+
+        assertFalse(ChannelManager.isRegistered(channel));
+        assertEquals(0, contextCount(channelMap("RM_CHANNELS")));
+    }
+
+    @Test
+    void closedChannelStaysIdentifiedUntilRetentionElapses() throws Exception {
+        Channel channel = channel(40001, false);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), channel);
+
+        handler.channelInactive(ctx(channel));
+        assertTrue(ChannelManager.isRegistered(channel));
+
+        server.elapseRetention();
+        assertFalse(ChannelManager.isRegistered(channel));
+    }
+
+    @Test
+    void responseForClosedTmChannelFallsBackToAnotherChannelOfTheSameClient() throws Exception {
+        Channel live = channel(40002, true);
+        when(live.isWritable()).thenReturn(true);
+        when(live.writeAndFlush(any())).thenReturn(mock(ChannelPromise.class));
+        Channel closed = channel(40003, false);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), live);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), closed);
+
+        handler.channelInactive(ctx(closed));
+        server.sendAsyncResponse(new RpcMessage(), closed, new GlobalCommitResponse());
+
+        verify(live).writeAndFlush(any(RpcMessage.class));
+        verify(closed, never()).writeAndFlush(any());
+    }
+
+    @Test
+    void requestQueuedBeforeCloseIsStillHandled() throws Exception {
+        Channel channel = channel(40004, false);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), channel);
+        ChannelHandlerContext ctx = ctx(channel);
+        TransactionMessageHandler transactionMessageHandler = mock(TransactionMessageHandler.class);
+        when(transactionMessageHandler.onRequest(any(), any())).thenReturn(new GlobalBeginResponse());
+        ServerOnRequestProcessor processor =
+                new ServerOnRequestProcessor(mock(RemotingServer.class), transactionMessageHandler);
+        RpcMessage request = new RpcMessage();
+        request.setBody(new GlobalBeginRequest());
+
+        handler.channelInactive(ctx);
+        processor.process(ctx, request);
+
+        verify(transactionMessageHandler).onRequest(any(GlobalBeginRequest.class), notNull());
+        verify(ctx, never()).close();
+    }
+
+    @Test
+    void exceptionOnOpenChannelKeepsItServable() throws Exception {
+        Channel channel = channel(40005, true);
+        ChannelManager.registerRMChannel(rmRequest(), channel);
+        ChannelHandlerContext ctx = ctx(channel);
+        RemotingProcessor processor = mock(RemotingProcessor.class);
+        server.registerProcessor(MessageType.TYPE_BRANCH_REGISTER, processor, null);
+
+        handler.exceptionCaught(ctx, new DecoderException("broken frame"));
+        assertTrue(ChannelManager.isRegistered(channel));
+
+        server.processMessage(ctx, mergedRequest(new BranchRegisterRequest(), 1));
+        verify(processor).process(eq(ctx), any(RpcMessage.class));
+    }
+
+    @Test
+    void mergedRequestFromUnidentifiedChannelIsDispatchedWhole() throws Exception {
+        ChannelHandlerContext ctx = ctx(channel(40006, true));
+        RemotingProcessor processor = mock(RemotingProcessor.class);
+        server.registerProcessor(MessageType.TYPE_SEATA_MERGE, processor, null);
+        RpcMessage merged = mergedRequest(new BranchRegisterRequest(), 1);
+
+        server.processMessage(ctx, merged);
+
+        verify(processor).process(ctx, merged);
+    }
+
+    @Test
+    void removalKeepsTheContextTheChannelIsBoundTo() throws Exception {
+        Channel channel = channel(40007, true);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), channel);
+
+        ChannelManager.removeIdentifiedChannel(channel, new RpcContext());
+
+        assertTrue(ChannelManager.isRegistered(channel));
+    }
+
+    @Test
+    void removalIsScheduledOnTheTimer() throws Exception {
+        Channel channel = channel(40008, false);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), channel);
+        server.captureRemovals = false;
+
+        handler.channelInactive(ctx(channel));
+
+        assertTrue(ChannelManager.isRegistered(channel));
+        ScheduledThreadPoolExecutor timer = (ScheduledThreadPoolExecutor) server.timerExecutor;
+        assertEquals(1, timer.getQueue().size());
+        assertTrue(((ScheduledFuture<?>) timer.getQueue().peek()).getDelay(TimeUnit.MILLISECONDS) > 0);
+    }
+
+    @Test
+    void removalRunsAtOnceWhenTheServerIsShuttingDown() throws Exception {
+        Channel channel = channel(40009, false);
+        ChannelManager.registerTMChannel(new RegisterTMRequest(APP_ID, TX_GROUP), channel);
+        server.captureRemovals = false;
+        server.timerExecutor.shutdown();
+
+        server.removeIdentifiedChannelLater(channel, ChannelManager.getContextFromIdentified(channel));
+
+        assertFalse(ChannelManager.isRegistered(channel));
+    }
+
+    private static RegisterRMRequest rmRequest() {
+        RegisterRMRequest request = new RegisterRMRequest(APP_ID, TX_GROUP);
+        request.setResourceIds(RESOURCE_ID);
+        return request;
+    }
+
+    private static RpcMessage registerRequest(RegisterRMRequest request) {
+        RpcMessage rpcMessage = new RpcMessage();
+        rpcMessage.setBody(request);
+        return rpcMessage;
+    }
+
+    private static RpcMessage mergedRequest(AbstractMessage message, int msgId) {
+        MergedWarpMessage mergedWarpMessage = new MergedWarpMessage();
+        mergedWarpMessage.msgs.add(message);
+        mergedWarpMessage.msgIds.add(msgId);
+        RpcMessage rpcMessage = new RpcMessage();
+        rpcMessage.setBody(mergedWarpMessage);
+        return rpcMessage;
+    }
+
+    private static Channel channel(int remotePort, boolean active) {
+        Channel channel = mock(Channel.class);
+        when(channel.remoteAddress()).thenReturn(new InetSocketAddress("127.0.0.1", remotePort));
+        when(channel.isActive()).thenReturn(active);
+        return channel;
+    }
+
+    private static ChannelHandlerContext ctx(Channel channel) {
+        ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
+        when(ctx.channel()).thenReturn(channel);
+        return ctx;
+    }
+
+    private static Map<?, ?> channelMap(String fieldName) throws Exception {
+        Field field = ChannelManager.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return (Map<?, ?>) field.get(null);
+    }
+
+    private static int contextCount(Map<?, ?> map) {
+        int count = 0;
+        for (Object value : map.values()) {
+            count += value instanceof Map ? contextCount((Map<?, ?>) value) : 1;
+        }
+        return count;
+    }
+
+    private static void clearChannelMaps() throws Exception {
+        channelMap("IDENTIFIED_CHANNELS").clear();
+        channelMap("TM_CHANNELS").clear();
+        channelMap("RM_CHANNELS").clear();
+    }
+
+    /**
+     * Holds the delayed removals back until the test lets the retention time elapse.
+     */
+    static class TestServer extends AbstractNettyRemotingServer {
+
+        private final List<Runnable> pendingRemovals = new ArrayList<>();
+
+        private boolean captureRemovals = true;
+
+        TestServer(ThreadPoolExecutor messageExecutor) {
+            super(messageExecutor, new NettyServerConfig());
+        }
+
+        @Override
+        public void destroyChannel(String serverAddress, Channel channel) {}
+
+        @Override
+        protected void removeIdentifiedChannelLater(Channel channel, RpcContext rpcContext) {
+            if (captureRemovals) {
+                pendingRemovals.add(() -> ChannelManager.removeIdentifiedChannel(channel, rpcContext));
+            } else {
+                super.removeIdentifiedChannelLater(channel, rpcContext);
+            }
+        }
+
+        void elapseRetention() {
+            pendingRemovals.forEach(Runnable::run);
+            pendingRemovals.clear();
+        }
+    }
+}
diff --git a/core/src/test/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessorTest.java b/core/src/test/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessorTest.java
index 6c978b3f2a..c3b554f30c 100644
--- a/core/src/test/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessorTest.java
+++ b/core/src/test/java/org/apache/seata/core/rpc/processor/server/ServerOnRequestProcessorTest.java
@@ -207,6 +207,37 @@ public class ServerOnRequestProcessorTest {
         verify(unregisteredCtx).close();
     }
 
+    @Test
+    public void testProcessKeepsContextWhenChannelIsReleasedConcurrently() throws Exception {
+        GlobalBeginRequest request = new GlobalBeginRequest();
+        when(transactionMessageHandler.onRequest(any(GlobalBeginRequest.class), any(RpcContext.class)))
+                .thenReturn(new GlobalBeginResponse());
+        RpcMessage rpcMessage = new RpcMessage() {
+            @Override
+            public Object getBody() {
+                // the IO thread drops the channel while the request is being handled
+                removeIdentifiedChannel(channel);
+                return super.getBody();
+            }
+        };
+        rpcMessage.setId(1);
+        rpcMessage.setBody(request);
+
+        processor.process(ctx, rpcMessage);
+
+        verify(transactionMessageHandler).onRequest(eq(request), eq(rpcContext));
+    }
+
+    private static void removeIdentifiedChannel(Channel channel) {
+        try {
+            Field field = ChannelManager.class.getDeclaredField("IDENTIFIED_CHANNELS");
+            field.setAccessible(true);
+            ((Map<?, ?>) field.get(null)).remove(channel);
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
     @Test
     public void testProcessNonAbstractMessage() throws Exception {
         RpcMessage rpcMessage = new RpcMessage();


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@seata.apache.org
For additional commands, e-mail: notifications-help@seata.apache.org