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

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new 5a012dd401 fix(mqtt): take over duplicate clientIds and fence 
stale-connection cleanup (#7918) (#7939)
5a012dd401 is described below

commit 5a012dd401643f7d23cf64e03b29bf20f57683d7
Author: konlue <[email protected]>
AuthorDate: Thu Oct 22 10:09:18 2026 +0800

    fix(mqtt): take over duplicate clientIds and fence stale-connection cleanup 
(#7918) (#7939)
    
    * fix(mqtt): take over duplicate clientIds and fence stale-connection 
cleanup (#7918)
    
    * fix(mqtt): release client-id ownership on disconnect and drop add() 
ownership write (#7918)
---
 .../org/apache/shenyu/protocol/mqtt/Connect.java   |  12 +-
 .../apache/shenyu/protocol/mqtt/Disconnect.java    |  17 +-
 .../mqtt/repositories/ChannelRepository.java       |  55 +++++-
 .../shenyu/protocol/mqtt/ClientIdTakeoverTest.java | 209 +++++++++++++++++++++
 .../protocol/mqtt/DisconnectDispatchTest.java      |   3 +-
 5 files changed, 282 insertions(+), 14 deletions(-)

diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Connect.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Connect.java
index f073aa5edd..99d1144c41 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Connect.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Connect.java
@@ -17,6 +17,7 @@
 
 package org.apache.shenyu.protocol.mqtt;
 
+import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.handler.codec.mqtt.MqttConnAckMessage;
 import io.netty.handler.codec.mqtt.MqttConnectMessage;
@@ -78,8 +79,15 @@ public class Connect extends MessageType {
             return;
         }
 
-        // record connect
-        Singleton.INST.get(ChannelRepository.class).add(ctx.channel(), 
clientId);
+        // record connect and take over the client id: a duplicate clientId 
must
+        // replace the existing connection instead of coexisting with it 
(#7918)
+        final Channel previous = 
Singleton.INST.get(ChannelRepository.class).takeover(ctx.channel(), clientId);
+        if (Objects.nonNull(previous) && previous != ctx.channel()) {
+            LOG.info("MQTT clientId {} is already connected, closing the 
previous connection.", clientId);
+            // the replaced connection's will is discarded: the client is 
alive on the new connection
+            Singleton.INST.get(WillRepository.class).remove(previous);
+            previous.close();
+        }
 
         // store will if present
         if (msg.variableHeader().isWillFlag()) {
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Disconnect.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Disconnect.java
index 4e3e919016..c73ef28e25 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Disconnect.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Disconnect.java
@@ -54,16 +54,21 @@ public class Disconnect extends MessageType {
      * @param channel disconnected channel
      */
     static void cleanChannel(final Channel channel) {
-        String clientId = 
Singleton.INST.get(ChannelRepository.class).get(channel);
+        final ChannelRepository channelRepository = 
Singleton.INST.get(ChannelRepository.class);
+        String clientId = channelRepository.get(channel);
         if (Objects.nonNull(clientId)) {
-            MqttSession session = 
Singleton.INST.get(SessionRepository.class).get(clientId);
-            if (Objects.nonNull(session) && session.isCleanSession()) {
-                // A clean-session disconnect discards all stored session 
state.
-                Singleton.INST.get(SessionRepository.class).remove(clientId);
+            // fenced on ownership: a replaced connection closing late must not
+            // discard the session of the connection that took over (#7918)
+            if (channelRepository.isOwner(clientId, channel)) {
+                MqttSession session = 
Singleton.INST.get(SessionRepository.class).get(clientId);
+                if (Objects.nonNull(session) && session.isCleanSession()) {
+                    // A clean-session disconnect discards all stored session 
state.
+                    
Singleton.INST.get(SessionRepository.class).remove(clientId);
+                }
             }
         }
         Singleton.INST.get(SubscribeRepository.class).remove(channel);
-        Singleton.INST.get(ChannelRepository.class).remove(channel);
+        channelRepository.remove(channel);
         MqttPacketIdGenerator.remove(channel);
     }
 }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/ChannelRepository.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/ChannelRepository.java
index fe81cd1379..2da7551353 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/ChannelRepository.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/ChannelRepository.java
@@ -20,28 +20,73 @@ package org.apache.shenyu.protocol.mqtt.repositories;
 import io.netty.channel.Channel;
 
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
- * channel repository.
+ * Channel and client-id ownership registry.
+ *
+ * <p>The ownership index is written only by {@link #takeover}: ownership of a
+ * client id is transferred atomically with the duplicate-clientId takeover, 
and
+ * {@link #remove} clears it with a conditional two-argument remove so a 
replaced
+ * connection closing late cannot evict the connection that took over 
(#7918).</p>
+ *
+ * <p>Note that on a takeover the replaced connection's stored will is 
discarded
+ * on purpose: the client is alive on the new connection, so publishing the 
will
+ * of the old one would be wrong. Other brokers publish it instead — this is a
+ * deliberate deviation, not a spec-mandated choice.</p>
  */
 public class ChannelRepository implements BaseRepository<Channel, String> {
 
-    private static final Map<Channel, String> CHANNEL_FACTORY = new 
ConcurrentHashMap<>();
+    private final Map<Channel, String> channelFactory = new 
ConcurrentHashMap<>();
+
+    private final Map<String, Channel> clientFactory = new 
ConcurrentHashMap<>();
 
     @Override
     public void add(final Channel channel, final String clientId) {
-        CHANNEL_FACTORY.put(channel, clientId);
+        channelFactory.put(channel, clientId);
     }
 
     @Override
     public void remove(final Channel channel) {
-        CHANNEL_FACTORY.remove(channel);
+        final String clientId = channelFactory.remove(channel);
+        if (Objects.nonNull(clientId)) {
+            // only clear ownership if this channel still holds it: a stale 
channel
+            // closing late must not evict the connection that took over 
(#7918)
+            clientFactory.remove(clientId, channel);
+        }
     }
 
     @Override
     public String get(final Channel channel) {
-        return CHANNEL_FACTORY.get(channel);
+        return channelFactory.get(channel);
     }
 
+    /**
+     * Transfers the ownership of a client id to the new channel, returning the
+     * channel that owned it before, if any (#7918). A duplicate clientId must
+     * replace the existing connection instead of coexisting with it.
+     *
+     * @param channel the channel of the new connection
+     * @param clientId the announced client identifier
+     * @return the previously owning channel, or null when the client was not 
connected
+     */
+    public synchronized Channel takeover(final Channel channel, final String 
clientId) {
+        final Channel previous = clientFactory.put(clientId, channel);
+        channelFactory.put(channel, clientId);
+        return previous;
+    }
+
+    /**
+     * Whether the channel still owns the client id. Cleanup of session-scoped
+     * state must be fenced on this, so a stale connection closing late cannot
+     * discard the state of the connection that replaced it (#7918).
+     *
+     * @param clientId the client identifier
+     * @param channel the channel closing
+     * @return true when the channel is the current owner of the client id
+     */
+    public boolean isOwner(final String clientId, final Channel channel) {
+        return clientFactory.get(clientId) == channel;
+    }
 }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ClientIdTakeoverTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ClientIdTakeoverTest.java
new file mode 100644
index 0000000000..45af4a5ece
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ClientIdTakeoverTest.java
@@ -0,0 +1,209 @@
+/*
+ * 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.shenyu.protocol.mqtt;
+
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.mqtt.MqttConnAckMessage;
+import io.netty.handler.codec.mqtt.MqttConnectMessage;
+import io.netty.handler.codec.mqtt.MqttConnectPayload;
+import io.netty.handler.codec.mqtt.MqttConnectVariableHeader;
+import io.netty.handler.codec.mqtt.MqttFixedHeader;
+import io.netty.handler.codec.mqtt.MqttMessageType;
+import io.netty.handler.codec.mqtt.MqttQoS;
+import io.netty.handler.codec.mqtt.MqttVersion;
+import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.WillRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import static 
io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_ACCEPTED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A duplicate clientId must replace the existing connection, and the replaced
+ * connection closing late must not discard the new session's state (#7918).
+ */
+public final class ClientIdTakeoverTest {
+
+    private static final String USER_NAME = "test-user";
+
+    private static final String PASSWORD = "test-password";
+
+    private static final String CLIENT_ID = "takeover-client";
+
+    private static final String WILL_TOPIC = "will/takeover";
+
+    private final List<EmbeddedChannel> channels = new ArrayList<>();
+
+    private ChannelRepository channelRepository;
+
+    private SessionRepository sessionRepository;
+
+    private WillRepository willRepository;
+
+    private SubscribeRepository subscribeRepository;
+
+    private Connect connect;
+
+    @BeforeEach
+    public void setUp() {
+        connect = new Connect();
+        channelRepository = new ChannelRepository();
+        sessionRepository = new SessionRepository();
+        willRepository = new WillRepository();
+        subscribeRepository = new SubscribeRepository();
+        Singleton.INST.single(ChannelRepository.class, channelRepository);
+        Singleton.INST.single(SessionRepository.class, sessionRepository);
+        Singleton.INST.single(WillRepository.class, willRepository);
+        Singleton.INST.single(SubscribeRepository.class, subscribeRepository);
+        MqttContext mqttContext = new MqttContext();
+        mqttContext.setUserName(USER_NAME);
+        mqttContext.setPassword(PASSWORD);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        for (EmbeddedChannel channel : channels) {
+            channelRepository.remove(channel);
+            willRepository.remove(channel);
+            channel.finishAndReleaseAll();
+        }
+        channels.clear();
+        MqttContext mqttContext = new MqttContext();
+        mqttContext.setUserName(null);
+        mqttContext.setPassword(null);
+    }
+
+    @Test
+    public void testTakeoverClosesThePreviousChannel() {
+        final EmbeddedChannel first = connect(CLIENT_ID, false);
+        assertAccepted(first);
+
+        final EmbeddedChannel second = connect(CLIENT_ID, false);
+        assertAccepted(second);
+
+        assertChannelClosed(first);
+        assertTrue(second.isActive());
+        assertTrue(channelRepository.isOwner(CLIENT_ID, second));
+    }
+
+    /**
+     * A clean disconnect must release the ownership, so the next connect with
+     * the same id is not mistaken for a duplicate-clientId takeover (#7918).
+     */
+    @Test
+    public void testCleanDisconnectReleasesOwnership() {
+        final EmbeddedChannel channel = connect(CLIENT_ID, false);
+        assertAccepted(channel);
+        assertTrue(channelRepository.isOwner(CLIENT_ID, channel));
+
+        Disconnect.cleanChannel(channel);
+
+        assertFalse(channelRepository.isOwner(CLIENT_ID, channel));
+    }
+
+    @Test
+    public void testStaleChannelCleanupKeepsTheNewSession() {
+        final EmbeddedChannel first = connect(CLIENT_ID, true);
+        final EmbeddedChannel second = connect(CLIENT_ID, true);
+        assertChannelClosed(first);
+
+        // the stale connection closes late, after the takeover: its cleanup
+        // must not discard the session the new connection just created (#7918)
+        Disconnect.cleanChannel(first);
+
+        assertNotNull(sessionRepository.get(CLIENT_ID),
+                "the stale connection's cleanup must not delete the new 
session");
+        assertNull(channelRepository.get(first));
+        assertTrue(channelRepository.isOwner(CLIENT_ID, second));
+    }
+
+    @Test
+    public void testWillOfReplacedConnectionIsDiscarded() {
+        final EmbeddedChannel first = connectWithWill(CLIENT_ID);
+        assertNotNull(willRepository.get(first));
+
+        final EmbeddedChannel second = connect(CLIENT_ID, false);
+        assertAccepted(second);
+
+        // the replaced connection's will is dropped: the client is alive on 
the new connection
+        assertNull(willRepository.get(first));
+        assertChannelClosed(first);
+        
assertFalse(subscribeRepository.getChannelsByTopic(WILL_TOPIC).containsKey(first));
+    }
+
+    private EmbeddedChannel connect(final String clientId, final boolean 
cleanSession) {
+        final EmbeddedChannel channel = newChannel();
+        connect.connect(context(channel), connectMessage(clientId, 
cleanSession, false, null, null));
+        return channel;
+    }
+
+    private EmbeddedChannel connectWithWill(final String clientId) {
+        final EmbeddedChannel channel = newChannel();
+        connect.connect(context(channel), connectMessage(clientId, true, true,
+                WILL_TOPIC, "gone".getBytes(StandardCharsets.UTF_8)));
+        return channel;
+    }
+
+    private void assertAccepted(final EmbeddedChannel channel) {
+        final MqttConnAckMessage ackMessage = channel.readOutbound();
+        assertNotNull(ackMessage);
+        assertEquals(CONNECTION_ACCEPTED, 
ackMessage.variableHeader().connectReturnCode());
+    }
+
+    private void assertChannelClosed(final EmbeddedChannel channel) {
+        channel.runPendingTasks();
+        assertFalse(channel.isActive());
+    }
+
+    private EmbeddedChannel newChannel() {
+        final EmbeddedChannel channel = new EmbeddedChannel(new 
ChannelInboundHandlerAdapter());
+        channels.add(channel);
+        return channel;
+    }
+
+    private ChannelHandlerContext context(final EmbeddedChannel channel) {
+        return channel.pipeline().lastContext();
+    }
+
+    private MqttConnectMessage connectMessage(final String clientId, final 
boolean cleanSession,
+            final boolean willFlag, final String willTopic, final byte[] 
willMessage) {
+        final MqttFixedHeader fixedHeader = new 
MqttFixedHeader(MqttMessageType.CONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0);
+        final MqttConnectVariableHeader variableHeader = new 
MqttConnectVariableHeader(
+                MqttVersion.MQTT_3_1_1.protocolName(), 
MqttVersion.MQTT_3_1_1.protocolLevel(),
+                cleanSession, willFlag, false, 0, Objects.nonNull(willTopic), 
false, 60);
+        final MqttConnectPayload payload = new MqttConnectPayload(clientId, 
willTopic, willMessage,
+                USER_NAME, PASSWORD.getBytes(StandardCharsets.UTF_8));
+        return new MqttConnectMessage(fixedHeader, variableHeader, payload);
+    }
+}
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/DisconnectDispatchTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/DisconnectDispatchTest.java
index d136d51d15..c58a59ac96 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/DisconnectDispatchTest.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/DisconnectDispatchTest.java
@@ -107,7 +107,8 @@ public class DisconnectDispatchTest {
     }
 
     private void registerChannel(final String clientId) {
-        Singleton.INST.get(ChannelRepository.class).add(channel, clientId);
+        // production registers ownership through takeover; add() no longer 
writes the index
+        Singleton.INST.get(ChannelRepository.class).takeover(channel, 
clientId);
         Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
                 assertEquals(clientId, 
Singleton.INST.get(ChannelRepository.class).get(channel)));
     }

Reply via email to