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 171165b947 fix: set MQTT sessionPresent from cleanSession flag and 
stored session state (#6889) (#6894)
171165b947 is described below

commit 171165b947477d5a08faf4a9dcc1459261fde8f2
Author: lll-peanut <[email protected]>
AuthorDate: Thu Oct 8 15:24:53 2026 +0800

    fix: set MQTT sessionPresent from cleanSession flag and stored session 
state (#6889) (#6894)
    
    * fix: set MQTT sessionPresent from cleanSession flag and stored session 
state (#6889)
    
    * test: add unit tests for MQTT session present handling (#6889)
    
    * fix: preserve MQTT session state on reconnect
    
    Store MQTT session subscriptions with their QoS and resume them through the 
QoS-aware subscribe repository path.
    
    Dispatch DISCONNECT in MqttFactory so clean-session state is removed on 
graceful disconnect, and unregister disconnected channels from subscriptions.
    
    Use computeIfAbsent in SubscribeRepository to avoid concurrent 
subscribe/resume overwrites.
    
    * chore: trigger CI
    
    * test: stabilize mqtt protocol tests
    
    * ci: retry mqtt build
    
    * ci: rerun integration tests
    
    ---------
    
    Co-authored-by: aias00 <[email protected]>
---
 .../org/apache/shenyu/protocol/mqtt/Connect.java   |  28 ++-
 .../apache/shenyu/protocol/mqtt/Disconnect.java    |  22 +-
 .../apache/shenyu/protocol/mqtt/MqttFactory.java   |   3 +
 .../shenyu/protocol/mqtt/MqttTransportHandler.java |  12 +-
 .../org/apache/shenyu/protocol/mqtt/Subscribe.java |  10 +
 .../apache/shenyu/protocol/mqtt/Unsubscribe.java   |   9 +
 .../protocol/mqtt/repositories/MqttSession.java    | 103 +++++++++
 .../mqtt/repositories/SessionRepository.java       |  45 ++++
 .../mqtt/repositories/SubscribeRepository.java     |  19 +-
 .../protocol/mqtt/ConnectSessionPresentTest.java   | 177 +++++++++++++++
 .../protocol/mqtt/DisconnectDispatchTest.java      | 117 ++++++++++
 .../shenyu/protocol/mqtt/MqttFactoryTest.java      |   8 +-
 .../protocol/mqtt/MqttSessionDisconnectTest.java   | 247 +++++++++++++++++++++
 .../protocol/mqtt/MqttTransportHandlerTest.java    |   3 +
 .../apache/shenyu/protocol/mqtt/PublishTest.java   |   3 +
 .../shenyu/protocol/mqtt/SubscribeQosTest.java     | 124 +++++++++++
 16 files changed, 904 insertions(+), 26 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 b23b07a661..157aa33412 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
@@ -26,9 +26,14 @@ import io.netty.handler.codec.mqtt.MqttVersion;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.shenyu.common.utils.Singleton;
 import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.Objects;
+
 import static io.netty.channel.ChannelFutureListener.CLOSE_ON_FAILURE;
 import static 
io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD;
 import static 
io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_IDENTIFIER_REJECTED;
@@ -74,9 +79,30 @@ public class Connect extends MessageType {
 
         // record connect
         Singleton.INST.get(ChannelRepository.class).add(ctx.channel(), 
clientId);
+
+        boolean cleanSession = msg.variableHeader().isCleanSession();
+        SessionRepository sessionRepository = 
Singleton.INST.get(SessionRepository.class);
+        MqttSession session = sessionRepository.get(clientId);
+        // MQTT-3.2.2-6/7: session present is only true when the client 
connects
+        // with cleanSession=0 and the server holds a stored session for the 
clientId.
+        boolean sessionPresent = !cleanSession && Objects.nonNull(session);
+        if (cleanSession) {
+            // A clean-session connect must discard any previously stored 
session.
+            session = new MqttSession(clientId, true);
+            sessionRepository.add(clientId, session);
+        } else if (Objects.isNull(session)) {
+            session = new MqttSession(clientId, false);
+            sessionRepository.add(clientId, session);
+        }
+        if (sessionPresent && !session.getTopics().isEmpty()) {
+            // Resume the stored subscriptions (with their QoS) for the new 
channel.
+            Singleton.INST.get(SubscribeRepository.class)
+                    .add(ctx.channel(), session.getTopicSubscriptions());
+        }
+
         MqttConnAckMessage ackMessage = MqttMessageBuilders.connAck()
                 .returnCode(MqttConnectReturnCode.CONNECTION_ACCEPTED)
-                .sessionPresent(true)
+                .sessionPresent(sessionPresent)
                 .build();
         ctx.writeAndFlush(ackMessage);
         setConnected(ctx.channel(), true);
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 d802247732..b71a849656 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
@@ -21,8 +21,13 @@ import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandlerContext;
 import org.apache.shenyu.common.utils.Singleton;
 import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.apache.shenyu.protocol.mqtt.utils.MqttPacketIdGenerator;
 
+import java.util.Objects;
+
 /**
  * The DISCONNECT message is sent from the client to the server to indicate
  * that it is about to close its TCP/IP connection.
@@ -38,13 +43,24 @@ public class Disconnect extends MessageType {
     @Override
     public void disconnect(final ChannelHandlerContext ctx) {
         //// todo Last words
-        //// todo Clean session
         cleanChannel(ctx.channel());
         ctx.close();
     }
 
-    private void cleanChannel(final Channel channel) {
-        //// todo ttl
+    /**
+     * Discard clean session state and release resources for a disconnected 
channel.
+     * @param channel disconnected channel
+     */
+    static void cleanChannel(final Channel channel) {
+        String clientId = 
Singleton.INST.get(ChannelRepository.class).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);
+            }
+        }
+        Singleton.INST.get(SubscribeRepository.class).remove(channel);
         Singleton.INST.get(ChannelRepository.class).remove(channel);
         MqttPacketIdGenerator.remove(channel);
     }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttFactory.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttFactory.java
index 59954045b5..0cc421aa11 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttFactory.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttFactory.java
@@ -69,7 +69,10 @@ public class MqttFactory {
                 messageType.pubRel(ctx, msg);
                 break;
             case PUBACK:
+                break;
             case DISCONNECT:
+                messageType.disconnect(ctx);
+                break;
             default:
                 break;
         }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandler.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandler.java
index c1d1181747..b177c0ed35 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandler.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandler.java
@@ -17,7 +17,6 @@
 
 package org.apache.shenyu.protocol.mqtt;
 
-import io.netty.channel.Channel;
 import io.netty.channel.ChannelFuture;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelInboundHandlerAdapter;
@@ -25,10 +24,6 @@ import io.netty.handler.codec.mqtt.MqttMessage;
 import io.netty.util.ReferenceCountUtil;
 import io.netty.util.concurrent.Future;
 import io.netty.util.concurrent.GenericFutureListener;
-import org.apache.shenyu.common.utils.Singleton;
-import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
-import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
-import org.apache.shenyu.protocol.mqtt.utils.MqttPacketIdGenerator;
 
 /**
  * mqtt transport handler.
@@ -51,16 +46,13 @@ public class MqttTransportHandler extends 
ChannelInboundHandlerAdapter implement
 
     @Override
     public void channelInactive(final ChannelHandlerContext ctx) throws 
Exception {
-        Singleton.INST.get(ChannelRepository.class).remove(ctx.channel());
+        Disconnect.cleanChannel(ctx.channel());
         ctx.fireChannelInactive();
     }
 
     @Override
     public void operationComplete(final Future<? super Void> future) throws 
Exception {
-        Channel channel = ((ChannelFuture) future).channel();
-        Singleton.INST.get(ChannelRepository.class).remove(channel);
-        Singleton.INST.get(SubscribeRepository.class).remove(channel);
-        MqttPacketIdGenerator.remove(channel);
+        Disconnect.cleanChannel(((ChannelFuture) future).channel());
     }
 
 }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Subscribe.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Subscribe.java
index 1a9975cfd1..246c3ca4a2 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Subscribe.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Subscribe.java
@@ -32,11 +32,15 @@ import io.netty.handler.codec.mqtt.MqttPublishMessage;
 import io.netty.util.CharsetUtil;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.TopicRepository;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 import java.util.stream.Collectors;
 
 import static io.netty.channel.ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE;
@@ -69,6 +73,12 @@ public class Subscribe extends MessageType {
 
         Singleton.INST.get(SubscribeRepository.class).add(ctx.channel(), 
mqttTopicSubscriptions);
 
+        String clientId = 
Singleton.INST.get(ChannelRepository.class).get(ctx.channel());
+        MqttSession session = 
Singleton.INST.get(SessionRepository.class).get(clientId);
+        if (Objects.nonNull(session)) {
+            mqttTopicSubscriptions.forEach(subscription -> 
session.addTopic(subscription.topicName(), subscription.qualityOfService()));
+        }
+
         for (String ackTopic : ackTopics) {
             String message = 
Singleton.INST.get(TopicRepository.class).get(ackTopic);
             if (StringUtils.isNotEmpty(message)) {
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Unsubscribe.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Unsubscribe.java
index 154b61af72..584b6260e5 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Unsubscribe.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Unsubscribe.java
@@ -25,9 +25,13 @@ import io.netty.handler.codec.mqtt.MqttQoS;
 import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage;
 import io.netty.handler.codec.mqtt.MqttUnsubAckMessage;
 import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 
 import java.util.List;
+import java.util.Objects;
 
 import static io.netty.channel.ChannelFutureListener.FIRE_EXCEPTION_ON_FAILURE;
 import static io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader.from;
@@ -46,6 +50,11 @@ public class Unsubscribe extends MessageType {
         List<String> topics = msg.payload().topics();
         Channel channel = ctx.channel();
         Singleton.INST.get(SubscribeRepository.class).remove(topics, channel);
+        String clientId = 
Singleton.INST.get(ChannelRepository.class).get(channel);
+        MqttSession session = 
Singleton.INST.get(SessionRepository.class).get(clientId);
+        if (Objects.nonNull(session)) {
+            topics.forEach(session::removeTopic);
+        }
         int packetId = msg.variableHeader().messageId();
         MqttFixedHeader mqttFixedHeader = new 
MqttFixedHeader(MqttMessageType.UNSUBACK, false, MqttQoS.AT_MOST_ONCE, false, 
0);
         MqttUnsubAckMessage mqttUnsubAckMessage = new 
MqttUnsubAckMessage(mqttFixedHeader, from(packetId));
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/MqttSession.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/MqttSession.java
new file mode 100644
index 0000000000..cedb97f7bd
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/MqttSession.java
@@ -0,0 +1,103 @@
+/*
+ * 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.repositories;
+
+import io.netty.handler.codec.mqtt.MqttQoS;
+import io.netty.handler.codec.mqtt.MqttTopicSubscription;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * MQTT session state stored for a client.
+ */
+public class MqttSession {
+
+    private final String clientId;
+
+    private final boolean cleanSession;
+
+    private final Map<String, MqttQoS> topics = new ConcurrentHashMap<>();
+
+    /**
+     * MqttSession constructor.
+     * @param clientId clientId
+     * @param cleanSession cleanSession
+     */
+    public MqttSession(final String clientId, final boolean cleanSession) {
+        this.clientId = clientId;
+        this.cleanSession = cleanSession;
+    }
+
+    /**
+     * get clientId.
+     * @return clientId
+     */
+    public String getClientId() {
+        return clientId;
+    }
+
+    /**
+     * get cleanSession.
+     * @return cleanSession
+     */
+    public boolean isCleanSession() {
+        return cleanSession;
+    }
+
+    /**
+     * add topic with its QoS, replacing any existing subscription for the 
same topic.
+     * @param topic topic
+     * @param qos qos
+     * @return true if the topic was newly added
+     */
+    public boolean addTopic(final String topic, final MqttQoS qos) {
+        return Objects.isNull(topics.put(topic, qos));
+    }
+
+    /**
+     * remove topic.
+     * @param topic topic
+     * @return true if the topic was removed
+     */
+    public boolean removeTopic(final String topic) {
+        return Objects.nonNull(topics.remove(topic));
+    }
+
+    /**
+     * get topics.
+     * @return topics
+     */
+    public Set<String> getTopics() {
+        return topics.keySet();
+    }
+
+    /**
+     * get topic subscriptions with their QoS for session resume.
+     * @return topic subscriptions
+     */
+    public List<MqttTopicSubscription> getTopicSubscriptions() {
+        return topics.entrySet().stream()
+                .map(entry -> new MqttTopicSubscription(entry.getKey(), 
entry.getValue()))
+                .collect(Collectors.toList());
+    }
+}
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SessionRepository.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SessionRepository.java
new file mode 100644
index 0000000000..510117968e
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SessionRepository.java
@@ -0,0 +1,45 @@
+/*
+ * 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.repositories;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Session repository, keyed by clientId.
+ */
+public class SessionRepository implements BaseRepository<String, MqttSession> {
+
+    private static final Map<String, MqttSession> SESSION_FACTORY = new 
ConcurrentHashMap<>();
+
+    @Override
+    public void add(final String clientId, final MqttSession session) {
+        SESSION_FACTORY.put(clientId, session);
+    }
+
+    @Override
+    public void remove(final String clientId) {
+        SESSION_FACTORY.remove(clientId);
+    }
+
+    @Override
+    public MqttSession get(final String clientId) {
+        return SESSION_FACTORY.get(clientId);
+    }
+
+}
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SubscribeRepository.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SubscribeRepository.java
index befd4a5c8e..2bd69d50c1 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SubscribeRepository.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/SubscribeRepository.java
@@ -27,7 +27,6 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 
 /**
@@ -41,10 +40,10 @@ public class SubscribeRepository implements 
BaseRepository<List<String>, Map<Cha
 
     @Override
     public void add(final List<String> topics, final Map<Channel, MqttQoS> 
channelQos) {
-        CompletableFuture.runAsync(() -> topics.parallelStream().forEach(topic 
->
+        topics.forEach(topic ->
                 channelQos.forEach((channel, qos) -> TOPIC_CHANNEL_FACTORY
                         .computeIfAbsent(topic, key -> new 
ConcurrentHashMap<>())
-                        .merge(channel, qos, SubscribeRepository::maxQoS))));
+                        .merge(channel, qos, SubscribeRepository::maxQoS)));
     }
 
     /**
@@ -53,16 +52,17 @@ public class SubscribeRepository implements 
BaseRepository<List<String>, Map<Cha
      * @param mqttTopicSubscription mqtt subscription info
      */
     public void add(final Channel channel, final List<MqttTopicSubscription> 
mqttTopicSubscription) {
-        CompletableFuture.runAsync(() -> mqttTopicSubscription.parallelStream()
+        // Complete registration before returning so a later disconnect cannot 
overtake it.
+        mqttTopicSubscription.stream()
                 .filter(s -> s.qualityOfService() != MqttQoS.FAILURE)
                 .forEach(s -> TOPIC_CHANNEL_FACTORY
                         .computeIfAbsent(s.topicName(), key -> new 
ConcurrentHashMap<>())
-                        .merge(channel, s.qualityOfService(), 
SubscribeRepository::maxQoS)));
+                        .merge(channel, s.qualityOfService(), 
SubscribeRepository::maxQoS));
     }
 
     @Override
     public void remove(final List<String> topics) {
-        CompletableFuture.runAsync(() -> 
topics.parallelStream().forEach(TOPIC_CHANNEL_FACTORY::remove));
+        topics.forEach(TOPIC_CHANNEL_FACTORY::remove);
     }
 
     /**
@@ -71,12 +71,12 @@ public class SubscribeRepository implements 
BaseRepository<List<String>, Map<Cha
      * @param channel channel
      */
     public void remove(final List<String> topics, final Channel channel) {
-        CompletableFuture.runAsync(() -> topics.parallelStream().forEach(topic 
-> {
+        topics.forEach(topic -> {
             Map<Channel, MqttQoS> subscribers = 
TOPIC_CHANNEL_FACTORY.get(topic);
             if (Objects.nonNull(subscribers)) {
                 subscribers.remove(channel);
             }
-        }));
+        });
     }
 
     /**
@@ -84,8 +84,7 @@ public class SubscribeRepository implements 
BaseRepository<List<String>, Map<Cha
      * @param channel channel
      */
     public void remove(final Channel channel) {
-        CompletableFuture.runAsync(() -> 
TOPIC_CHANNEL_FACTORY.values().parallelStream()
-                .forEach(subscribers -> subscribers.remove(channel)));
+        TOPIC_CHANNEL_FACTORY.values().forEach(subscribers -> 
subscribers.remove(channel));
     }
 
     @Override
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectSessionPresentTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectSessionPresentTest.java
new file mode 100644
index 0000000000..c2b57536ba
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectSessionPresentTest.java
@@ -0,0 +1,177 @@
+/*
+ * 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.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelHandlerContext;
+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.MqttMessageBuilders;
+import io.netty.handler.codec.mqtt.MqttQoS;
+import io.netty.handler.codec.mqtt.MqttTopicSubscription;
+import io.netty.handler.codec.mqtt.MqttVersion;
+import io.netty.util.Attribute;
+import io.netty.util.AttributeKey;
+import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static 
io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_UNACCEPTABLE_PROTOCOL_VERSION;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test case for {@link Connect} session present behavior.
+ */
+public class ConnectSessionPresentTest {
+
+    private Connect connect;
+
+    private ChannelHandlerContext ctx;
+
+    private Channel channel;
+
+    private Attribute<Boolean> connectedAttribute;
+
+    @BeforeEach
+    public void setUp() {
+        Singleton.INST.single(ChannelRepository.class, new 
ChannelRepository());
+        Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
+        Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
+        MqttContext mqttContext = new MqttContext();
+        mqttContext.setUserName("shenyu");
+        mqttContext.setPassword("shenyu");
+        connect = new Connect();
+        ctx = mock(ChannelHandlerContext.class);
+        channel = mock(Channel.class);
+        connectedAttribute = mock(Attribute.class);
+        ChannelFuture channelFuture = mock(ChannelFuture.class);
+        when(ctx.channel()).thenReturn(channel);
+        
when(channel.attr(any(AttributeKey.class))).thenReturn(connectedAttribute);
+        when(connectedAttribute.get()).thenReturn(false);
+        when(ctx.writeAndFlush(any())).thenReturn(channelFuture);
+        when(ctx.close()).thenReturn(channelFuture);
+    }
+
+    @Test
+    public void firstConnectWithCleanSessionFalseShouldNotPresentSession() {
+        MqttConnAckMessage ack = connectWithCleanSession("client-first", 
false);
+        assertFalse(ack.variableHeader().isSessionPresent());
+    }
+
+    @Test
+    public void reconnectWithCleanSessionFalseShouldPresentSession() {
+        String clientId = "client-reconnect";
+        assertFalse(connectWithCleanSession(clientId, 
false).variableHeader().isSessionPresent());
+        assertTrue(connectWithCleanSession(clientId, 
false).variableHeader().isSessionPresent());
+    }
+
+    @Test
+    public void connectWithCleanSessionTrueShouldNotPresentSession() {
+        MqttConnAckMessage ack = connectWithCleanSession("client-clean", true);
+        assertFalse(ack.variableHeader().isSessionPresent());
+    }
+
+    @Test
+    public void cleanSessionConnectShouldDiscardPreviousSession() {
+        String clientId = "client-discard";
+        assertFalse(connectWithCleanSession(clientId, 
false).variableHeader().isSessionPresent());
+        assertFalse(connectWithCleanSession(clientId, 
true).variableHeader().isSessionPresent());
+        MqttSession session = 
Singleton.INST.get(SessionRepository.class).get(clientId);
+        assertTrue(session.isCleanSession());
+        assertTrue(session.getTopics().isEmpty());
+    }
+
+    @Test
+    public void resumeSessionShouldReRegisterSubscriptionsToNewChannel() {
+        String clientId = "client-resume";
+        MqttSession storedSession = new MqttSession(clientId, false);
+        storedSession.addTopic("test/topic", MqttQoS.AT_LEAST_ONCE);
+        Singleton.INST.get(SessionRepository.class).add(clientId, 
storedSession);
+
+        MqttConnAckMessage ack = connectWithCleanSession(clientId, false);
+        assertTrue(ack.variableHeader().isSessionPresent());
+
+        MqttSession session = 
Singleton.INST.get(SessionRepository.class).get(clientId);
+        assertTrue(session.getTopics().contains("test/topic"));
+        List<MqttTopicSubscription> subscriptions = 
session.getTopicSubscriptions();
+        assertEquals(1, subscriptions.size());
+        assertEquals("test/topic", subscriptions.get(0).topicName());
+        assertEquals(MqttQoS.AT_LEAST_ONCE, 
subscriptions.get(0).qualityOfService());
+
+        SubscribeRepository subscribeRepository = 
Singleton.INST.get(SubscribeRepository.class);
+        Awaitility.await().atMost(5, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
assertTrue(subscribeRepository.get("test/topic").containsKey(channel)));
+    }
+
+    @Test
+    public void 
unsupportedProtocolVersionShouldNotAuthenticateOrStoreSession() {
+        MqttConnectMessage msg = new MqttConnectMessage(
+                new MqttFixedHeader(MqttMessageType.CONNECT, false, 
MqttQoS.AT_MOST_ONCE, false, 0),
+                new MqttConnectVariableHeader("MQTT", 6, true, true, false, 0, 
false, false, 60),
+                new MqttConnectPayload("client-mqtt-6", null, null, "shenyu",
+                        "shenyu".getBytes(StandardCharsets.UTF_8)));
+
+        connect.connect(ctx, msg);
+
+        ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
+        verify(ctx).writeAndFlush(captor.capture());
+        MqttConnAckMessage ack = (MqttConnAckMessage) captor.getValue();
+        assertEquals(CONNECTION_REFUSED_UNACCEPTABLE_PROTOCOL_VERSION, 
ack.variableHeader().connectReturnCode());
+        verify(ctx).channel();
+        verify(ctx).close();
+        
assertNull(Singleton.INST.get(SessionRepository.class).get("client-mqtt-6"));
+    }
+
+    private MqttConnAckMessage connectWithCleanSession(final String clientId, 
final boolean cleanSession) {
+        MqttConnectMessage msg = MqttMessageBuilders.connect()
+                .clientId(clientId)
+                .cleanSession(cleanSession)
+                .protocolVersion(MqttVersion.MQTT_3_1)
+                .username("shenyu")
+                .password("shenyu".getBytes(StandardCharsets.UTF_8))
+                .build();
+        clearInvocations(ctx);
+        connect.connect(ctx, msg);
+        ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
+        verify(ctx).writeAndFlush(captor.capture());
+        return (MqttConnAckMessage) captor.getValue();
+    }
+}
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
new file mode 100644
index 0000000000..6509ddd686
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/DisconnectDispatchTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.Channel;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.mqtt.MqttMessageBuilders;
+import io.netty.handler.codec.mqtt.MqttQoS;
+import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test case for {@link MqttFactory} DISCONNECT dispatch and clean-session 
cleanup.
+ */
+public class DisconnectDispatchTest {
+
+    private ChannelHandlerContext ctx;
+
+    private Channel channel;
+
+    @BeforeEach
+    public void setUp() {
+        Singleton.INST.single(ChannelRepository.class, new 
ChannelRepository());
+        Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
+        Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
+        ctx = mock(ChannelHandlerContext.class);
+        channel = mock(Channel.class);
+        when(ctx.channel()).thenReturn(channel);
+    }
+
+    @Test
+    public void cleanSessionDisconnectShouldRemoveStoredSession() {
+        String clientId = "client-clean";
+        registerChannel(clientId);
+        Singleton.INST.get(SessionRepository.class).add(clientId, new 
MqttSession(clientId, true));
+
+        new MqttFactory(MqttMessageBuilders.disconnect().build(), 
ctx).connect();
+
+        assertNull(Singleton.INST.get(SessionRepository.class).get(clientId));
+    }
+
+    @Test
+    public void 
persistentSessionDisconnectShouldKeepStoredSessionAndUnregisterChannel() {
+        String clientId = "client-persistent";
+        String topic = "test/persistent";
+        registerChannel(clientId);
+        MqttSession session = new MqttSession(clientId, false);
+        session.addTopic(topic, MqttQoS.AT_MOST_ONCE);
+        Singleton.INST.get(SessionRepository.class).add(clientId, session);
+        Singleton.INST.get(SubscribeRepository.class).add(channel, 
session.getTopicSubscriptions());
+        awaitSubscribed(topic);
+
+        new MqttFactory(MqttMessageBuilders.disconnect().build(), 
ctx).connect();
+
+        
assertNotNull(Singleton.INST.get(SessionRepository.class).get(clientId));
+        Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
+                
assertFalse(Singleton.INST.get(SubscribeRepository.class).get(topic).containsKey(channel)));
+    }
+
+    @Test
+    public void cleanSessionDisconnectShouldUnregisterSubscribedChannel() {
+        String clientId = "client-clean-sub";
+        String topic = "test/clean-sub";
+        registerChannel(clientId);
+        MqttSession session = new MqttSession(clientId, true);
+        session.addTopic(topic, MqttQoS.AT_MOST_ONCE);
+        Singleton.INST.get(SessionRepository.class).add(clientId, session);
+        Singleton.INST.get(SubscribeRepository.class).add(channel, 
session.getTopicSubscriptions());
+        awaitSubscribed(topic);
+
+        new MqttFactory(MqttMessageBuilders.disconnect().build(), 
ctx).connect();
+
+        Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
+                
assertFalse(Singleton.INST.get(SubscribeRepository.class).get(topic).containsKey(channel)));
+    }
+
+    private void registerChannel(final String clientId) {
+        Singleton.INST.get(ChannelRepository.class).add(channel, clientId);
+        Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
+                assertEquals(clientId, 
Singleton.INST.get(ChannelRepository.class).get(channel)));
+    }
+
+    private void awaitSubscribed(final String topic) {
+        Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
+                assertEquals(1, 
Singleton.INST.get(SubscribeRepository.class).get(topic).size()));
+    }
+}
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttFactoryTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttFactoryTest.java
index deb0550121..cecdd764d2 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttFactoryTest.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttFactoryTest.java
@@ -40,6 +40,8 @@ import io.netty.handler.codec.mqtt.MqttUnsubscribePayload;
 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.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -65,6 +67,8 @@ public final class MqttFactoryTest {
     @BeforeEach
     public void setUp() {
         Singleton.INST.single(ChannelRepository.class, new 
ChannelRepository());
+        Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
+        Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
         new MqttContext().setUserName(USER_NAME);
         new MqttContext().setPassword(PASSWORD);
     }
@@ -149,13 +153,13 @@ public final class MqttFactoryTest {
     }
 
     @Test
-    public void disconnectShouldFallThroughToNoOp() {
+    public void disconnectShouldCloseChannel() {
         EmbeddedChannel channel = new EmbeddedChannel(new 
ChannelInboundHandlerAdapter());
         ChannelHandlerContext ctx = channel.pipeline().lastContext();
 
         new MqttFactory(new 
MqttMessage(fixedHeader(MqttMessageType.DISCONNECT)), ctx).connect();
 
-        assertTrue(channel.isActive());
+        assertFalse(channel.isActive());
         channel.finishAndReleaseAll();
     }
 
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttSessionDisconnectTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttSessionDisconnectTest.java
new file mode 100644
index 0000000000..e42a6aaf5c
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttSessionDisconnectTest.java
@@ -0,0 +1,247 @@
+/*
+ * 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.embedded.EmbeddedChannel;
+import io.netty.handler.codec.mqtt.MqttConnAckMessage;
+import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
+import io.netty.handler.codec.mqtt.MqttMessage;
+import io.netty.handler.codec.mqtt.MqttMessageBuilders;
+import io.netty.handler.codec.mqtt.MqttMessageType;
+import io.netty.handler.codec.mqtt.MqttQoS;
+import io.netty.handler.codec.mqtt.MqttTopicSubscription;
+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.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.TopicRepository;
+import org.apache.shenyu.protocol.mqtt.utils.MqttPacketIdGenerator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.awaitility.Awaitility.await;
+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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Regression tests for session cleanup on graceful and abnormal disconnects.
+ */
+public final class MqttSessionDisconnectTest {
+
+    private static final String CLIENT_ID = "session-disconnect-client";
+
+    private static final String TOPIC = "test/session-disconnect";
+
+    private static final String OTHER_TOPIC = "test/session-disconnect-other";
+
+    private static final String USER_NAME = "test-user";
+
+    private static final String PASSWORD = "test-password";
+
+    private final List<EmbeddedChannel> channels = new ArrayList<>();
+
+    private final ChannelRepository channelRepository = new 
ChannelRepository();
+
+    private final SessionRepository sessionRepository = new 
SessionRepository();
+
+    private final SubscribeRepository subscribeRepository = new 
SubscribeRepository();
+
+    @BeforeEach
+    public void setUp() {
+        Singleton.INST.single(ChannelRepository.class, channelRepository);
+        Singleton.INST.single(SessionRepository.class, sessionRepository);
+        Singleton.INST.single(SubscribeRepository.class, subscribeRepository);
+        Singleton.INST.single(TopicRepository.class, new TopicRepository());
+        sessionRepository.remove(CLIENT_ID);
+        new MqttContext().setUserName(USER_NAME);
+        new MqttContext().setPassword(PASSWORD);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        channels.forEach(channel -> {
+            channel.finishAndReleaseAll();
+            channelRepository.remove(channel);
+            subscribeRepository.remove(channel);
+            MqttPacketIdGenerator.remove(channel);
+        });
+        await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> 
channels.forEach(channel -> {
+            assertFalse(subscribeRepository.get(TOPIC).containsKey(channel));
+            
assertFalse(subscribeRepository.get(OTHER_TOPIC).containsKey(channel));
+        }));
+        sessionRepository.remove(CLIENT_ID);
+        new MqttContext().setUserName(null);
+        new MqttContext().setPassword(null);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void cleanSessionDisconnectDiscardsStateBeforeReconnect(final 
boolean graceful) {
+        EmbeddedChannel channel = connect(true, false);
+        subscribe(channel);
+        assertTrue(sessionRepository.get(CLIENT_ID).isCleanSession());
+        assertEquals(2, sessionRepository.get(CLIENT_ID).getTopics().size());
+        assertEquals(1, MqttPacketIdGenerator.next(channel));
+
+        disconnect(channel, graceful);
+
+        assertFalse(channel.isActive());
+        assertNull(channelRepository.get(channel));
+        assertNull(sessionRepository.get(CLIENT_ID));
+        assertUnsubscribed(channel);
+        assertEquals(1, MqttPacketIdGenerator.next(channel));
+
+        EmbeddedChannel reconnected = connect(false, false);
+        assertTrue(sessionRepository.get(CLIENT_ID).getTopics().isEmpty());
+        assertUnsubscribed(reconnected);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
persistentSessionDisconnectPreservesSubscriptionsForReconnect(final boolean 
graceful) {
+        EmbeddedChannel channel = connect(false, false);
+        subscribe(channel);
+        final MqttSession session = sessionRepository.get(CLIENT_ID);
+
+        disconnect(channel, graceful);
+
+        assertFalse(channel.isActive());
+        assertNull(channelRepository.get(channel));
+        assertSame(session, sessionRepository.get(CLIENT_ID));
+        assertEquals(2, session.getTopics().size());
+        assertUnsubscribed(channel);
+
+        EmbeddedChannel reconnected = connect(false, true);
+        await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+            assertEquals(MqttQoS.AT_LEAST_ONCE, 
subscribeRepository.get(TOPIC).get(reconnected));
+            assertEquals(MqttQoS.EXACTLY_ONCE, 
subscribeRepository.get(OTHER_TOPIC).get(reconnected));
+        });
+        assertUnsubscribed(channel);
+    }
+
+    @Test
+    public void closeFutureDiscardsCleanSessionBeforeChannelInactive() {
+        EmbeddedChannel channel = connect(true, false);
+        channel.closeFuture().addListener(new MqttTransportHandler());
+        subscribe(channel);
+
+        channel.close();
+
+        assertNull(sessionRepository.get(CLIENT_ID));
+        assertNull(channelRepository.get(channel));
+        assertUnsubscribed(channel);
+    }
+
+    @Test
+    public void 
abruptCloseImmediatelyAfterSubscribeRemovesOnlyDisconnectedChannel() {
+        EmbeddedChannel otherChannel = newChannel();
+        subscribeRepository.add(otherChannel,
+                Collections.singletonList(new MqttTopicSubscription(TOPIC, 
MqttQoS.AT_MOST_ONCE)));
+        EmbeddedChannel channel = connect(true, false);
+        channel.writeInbound(MqttMessageBuilders.subscribe()
+                .messageId(1)
+                .addSubscription(MqttQoS.AT_LEAST_ONCE, TOPIC)
+                .build());
+
+        channel.close();
+
+        assertNull(sessionRepository.get(CLIENT_ID));
+        assertFalse(subscribeRepository.get(TOPIC).containsKey(channel));
+        assertEquals(MqttQoS.AT_MOST_ONCE, 
subscribeRepository.get(TOPIC).get(otherChannel));
+    }
+
+    @Test
+    public void channelWithoutStoredSessionCanClose() {
+        EmbeddedChannel channel = newChannel();
+        channelRepository.add(channel, CLIENT_ID);
+
+        channel.close();
+
+        assertFalse(channel.isActive());
+        assertNull(channelRepository.get(channel));
+        assertNull(sessionRepository.get(CLIENT_ID));
+    }
+
+    private EmbeddedChannel connect(final boolean cleanSession, final boolean 
sessionPresent) {
+        EmbeddedChannel channel = newChannel();
+        channel.writeInbound(MqttMessageBuilders.connect()
+                .clientId(CLIENT_ID)
+                .cleanSession(cleanSession)
+                .protocolVersion(MqttVersion.MQTT_3_1_1)
+                .username(USER_NAME)
+                .password(PASSWORD.getBytes(StandardCharsets.UTF_8))
+                .build());
+        MqttConnAckMessage ack = channel.readOutbound();
+        assertNotNull(ack);
+        assertEquals(MqttConnectReturnCode.CONNECTION_ACCEPTED, 
ack.variableHeader().connectReturnCode());
+        assertEquals(sessionPresent, ack.variableHeader().isSessionPresent());
+        return channel;
+    }
+
+    private EmbeddedChannel newChannel() {
+        EmbeddedChannel channel = new EmbeddedChannel(new 
MqttTransportHandler());
+        channels.add(channel);
+        return channel;
+    }
+
+    private void subscribe(final EmbeddedChannel channel) {
+        channel.writeInbound(MqttMessageBuilders.subscribe()
+                .messageId(1)
+                .addSubscription(MqttQoS.AT_LEAST_ONCE, TOPIC)
+                .addSubscription(MqttQoS.EXACTLY_ONCE, OTHER_TOPIC)
+                .build());
+        MqttMessage ack = channel.readOutbound();
+        assertNotNull(ack);
+        assertEquals(MqttMessageType.SUBACK, ack.fixedHeader().messageType());
+        await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+            assertEquals(MqttQoS.AT_LEAST_ONCE, 
subscribeRepository.get(TOPIC).get(channel));
+            assertEquals(MqttQoS.EXACTLY_ONCE, 
subscribeRepository.get(OTHER_TOPIC).get(channel));
+        });
+    }
+
+    private void disconnect(final EmbeddedChannel channel, final boolean 
graceful) {
+        if (graceful) {
+            channel.writeInbound(MqttMessageBuilders.disconnect().build());
+        } else {
+            channel.close();
+        }
+        channel.runPendingTasks();
+    }
+
+    private void assertUnsubscribed(final EmbeddedChannel channel) {
+        await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
+            assertFalse(subscribeRepository.get(TOPIC).containsKey(channel));
+            
assertFalse(subscribeRepository.get(OTHER_TOPIC).containsKey(channel));
+        });
+    }
+}
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandlerTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandlerTest.java
index 58c2466579..d2aeaf37c6 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandlerTest.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/MqttTransportHandlerTest.java
@@ -33,6 +33,7 @@ import io.netty.handler.codec.mqtt.MqttVersion;
 import io.netty.util.CharsetUtil;
 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.utils.MqttPacketIdGenerator;
 import org.awaitility.core.ThrowingRunnable;
@@ -80,6 +81,7 @@ public final class MqttTransportHandlerTest {
     @BeforeEach
     public void setUp() {
         Singleton.INST.single(ChannelRepository.class, CHANNEL_REPOSITORY);
+        Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
         Singleton.INST.single(SubscribeRepository.class, SUBSCRIBE_REPOSITORY);
         new MqttContext().setUserName(USER_NAME);
         new MqttContext().setPassword(PASSWORD);
@@ -96,6 +98,7 @@ public final class MqttTransportHandlerTest {
 
     @AfterEach
     public void tearDown() {
+        Singleton.INST.get(SessionRepository.class).remove(CLIENT_ID);
         MqttPacketIdGenerator.remove(registeredChannel);
         CHANNEL_REPOSITORY.remove(registeredChannel);
         SUBSCRIBE_REPOSITORY.remove(registeredChannel);
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PublishTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PublishTest.java
index a840b5dc0f..72fe5bc5eb 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PublishTest.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PublishTest.java
@@ -41,6 +41,7 @@ import io.netty.util.ReferenceCountUtil;
 import org.apache.shenyu.common.utils.Singleton;
 import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.TopicRepository;
 import org.apache.shenyu.protocol.mqtt.utils.MqttPacketIdGenerator;
 import org.awaitility.core.ThrowingRunnable;
@@ -122,6 +123,7 @@ public final class PublishTest {
         Singleton.INST.single(SubscribeRepository.class, SUBSCRIBE_REPOSITORY);
         Singleton.INST.single(TopicRepository.class, TOPIC_REPOSITORY);
         Singleton.INST.single(ChannelRepository.class, CHANNEL_REPOSITORY);
+        Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
         new MqttContext().setUserName(USER_NAME);
         new MqttContext().setPassword(PASSWORD);
 
@@ -400,6 +402,7 @@ public final class PublishTest {
     }
 
     private void clearSharedState() {
+        Singleton.INST.get(SessionRepository.class).remove(CLIENT_ID);
         ALL_TOPICS.forEach(TOPIC_REPOSITORY::remove);
         SUBSCRIBE_REPOSITORY.remove(ALL_TOPICS);
         awaitAssert(() -> ALL_TOPICS.forEach(topic -> 
assertTrue(SUBSCRIBE_REPOSITORY.get(topic).isEmpty())));
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/SubscribeQosTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/SubscribeQosTest.java
new file mode 100644
index 0000000000..66dd613b32
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/SubscribeQosTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.mqtt.MqttMessageBuilders;
+import io.netty.handler.codec.mqtt.MqttQoS;
+import io.netty.handler.codec.mqtt.MqttSubscribeMessage;
+import io.netty.handler.codec.mqtt.MqttTopicSubscription;
+import io.netty.util.Attribute;
+import io.netty.util.AttributeKey;
+import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
+import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.TopicRepository;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test case for {@link Subscribe} QoS retention in {@link MqttSession}.
+ */
+public class SubscribeQosTest {
+
+    private ChannelHandlerContext ctx;
+
+    private Channel channel;
+
+    private Attribute<Boolean> connectedAttribute;
+
+    @BeforeEach
+    public void setUp() {
+        Singleton.INST.single(ChannelRepository.class, new 
ChannelRepository());
+        Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
+        Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
+        Singleton.INST.single(TopicRepository.class, new TopicRepository());
+        ctx = mock(ChannelHandlerContext.class);
+        channel = mock(Channel.class);
+        connectedAttribute = mock(Attribute.class);
+        when(ctx.channel()).thenReturn(channel);
+        
when(channel.attr(any(AttributeKey.class))).thenReturn(connectedAttribute);
+        when(connectedAttribute.get()).thenReturn(true);
+        when(ctx.writeAndFlush(any())).thenReturn(mock(ChannelFuture.class));
+        
when(channel.writeAndFlush(any())).thenReturn(mock(ChannelFuture.class));
+    }
+
+    @Test
+    public void subscribeShouldStoreQosInSession() {
+        String clientId = "client-qos";
+        Singleton.INST.get(ChannelRepository.class).add(channel, clientId);
+        MqttSession session = new MqttSession(clientId, false);
+        Singleton.INST.get(SessionRepository.class).add(clientId, session);
+        Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
+                assertEquals(clientId, 
Singleton.INST.get(ChannelRepository.class).get(channel)));
+
+        MqttSubscribeMessage msg = MqttMessageBuilders.subscribe()
+                .messageId(1)
+                .addSubscription(MqttQoS.AT_LEAST_ONCE, "test/qos")
+                .addSubscription(MqttQoS.EXACTLY_ONCE, "test/qos2")
+                .build();
+
+        new Subscribe().subscribe(ctx, msg);
+
+        MqttTopicSubscription qos1 = subscription(session, "test/qos");
+        MqttTopicSubscription qos2 = subscription(session, "test/qos2");
+        assertEquals(MqttQoS.AT_LEAST_ONCE, qos1.qualityOfService());
+        assertEquals(MqttQoS.EXACTLY_ONCE, qos2.qualityOfService());
+    }
+
+    @Test
+    public void resubscribeShouldReplacePreviousQos() {
+        String clientId = "client-resub";
+        Singleton.INST.get(ChannelRepository.class).add(channel, clientId);
+        MqttSession session = new MqttSession(clientId, false);
+        Singleton.INST.get(SessionRepository.class).add(clientId, session);
+        Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
+                assertEquals(clientId, 
Singleton.INST.get(ChannelRepository.class).get(channel)));
+
+        new Subscribe().subscribe(ctx, MqttMessageBuilders.subscribe()
+                .messageId(1)
+                .addSubscription(MqttQoS.EXACTLY_ONCE, "test/qos")
+                .build());
+        new Subscribe().subscribe(ctx, MqttMessageBuilders.subscribe()
+                .messageId(2)
+                .addSubscription(MqttQoS.AT_MOST_ONCE, "test/qos")
+                .build());
+
+        MqttTopicSubscription subscription = subscription(session, "test/qos");
+        assertEquals(MqttQoS.AT_MOST_ONCE, subscription.qualityOfService());
+    }
+
+    private MqttTopicSubscription subscription(final MqttSession session, 
final String topic) {
+        return session.getTopicSubscriptions().stream()
+                .filter(subscription -> topic.equals(subscription.topicName()))
+                .findFirst()
+                .orElseThrow(AssertionError::new);
+    }
+}

Reply via email to