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 3fc3d84eab fix(mqtt): deduplicate retransmitted qos2 publishes with 
inbound in-flight state (#7919) (#7938)
3fc3d84eab is described below

commit 3fc3d84eabaa32e0573eb48778065976e4be12f3
Author: konlue <[email protected]>
AuthorDate: Fri Oct 23 11:21:47 2026 +0800

    fix(mqtt): deduplicate retransmitted qos2 publishes with inbound in-flight 
state (#7919) (#7938)
    
    * fix(mqtt): deduplicate retransmitted qos2 publishes with inbound 
in-flight state (#7919)
    
    * fix(mqtt): clear inbound in-flight ids on new sessions and harden dedup 
tests (#7919)
---
 .../org/apache/shenyu/protocol/mqtt/Connect.java   |  31 ++++--
 .../apache/shenyu/protocol/mqtt/Disconnect.java    |   5 +-
 .../org/apache/shenyu/protocol/mqtt/PubRel.java    |   9 ++
 .../org/apache/shenyu/protocol/mqtt/Publish.java   |  61 ++++++++++--
 .../mqtt/repositories/InflightRepository.java      |  80 +++++++++++++++
 .../shenyu/protocol/mqtt/ClientIdTakeoverTest.java |   3 +
 .../protocol/mqtt/ConnectSessionPresentTest.java   |   2 +
 .../apache/shenyu/protocol/mqtt/ConnectTest.java   |   2 +
 .../protocol/mqtt/DisconnectDispatchTest.java      |   2 +
 .../shenyu/protocol/mqtt/MqttFactoryTest.java      |   3 +
 .../apache/shenyu/protocol/mqtt/PubRelTest.java    |  25 +++++
 .../apache/shenyu/protocol/mqtt/PublishTest.java   | 109 ++++++++++++++++++++-
 12 files changed, 313 insertions(+), 19 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 99d1144c41..1d04db87fa 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
@@ -27,6 +27,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
@@ -81,13 +82,7 @@ public class Connect extends MessageType {
 
         // 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();
-        }
+        takeOverClientId(ctx.channel(), clientId);
 
         // store will if present
         if (msg.variableHeader().isWillFlag()) {
@@ -118,6 +113,11 @@ public class Connect extends MessageType {
             Singleton.INST.get(SubscribeRepository.class)
                     .add(ctx.channel(), session.getTopicSubscriptions());
         }
+        if (!sessionPresent) {
+            // a new session resets the packet id space, so stale inbound ids 
must
+            // not suppress new publishes on this connection (#7919)
+            Singleton.INST.get(InflightRepository.class).remove(clientId);
+        }
 
         MqttConnAckMessage ackMessage = MqttMessageBuilders.connAck()
                 .returnCode(MqttConnectReturnCode.CONNECTION_ACCEPTED)
@@ -127,6 +127,23 @@ public class Connect extends MessageType {
         setConnected(ctx.channel(), true);
     }
 
+    /**
+     * Takes over the client id for the new channel, closing the connection 
that
+     * owned it before, if any.
+     *
+     * @param channel the channel of the new connection
+     * @param clientId the announced client identifier
+     */
+    private void takeOverClientId(final Channel channel, final String 
clientId) {
+        final Channel previous = 
Singleton.INST.get(ChannelRepository.class).takeover(channel, clientId);
+        if (Objects.nonNull(previous) && previous != 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();
+        }
+    }
+
     private void close(final ChannelHandlerContext ctx, final 
MqttConnectReturnCode returnCode) {
         ctx.writeAndFlush(wrong(returnCode));
         ctx.close().addListener(CLOSE_ON_FAILURE);
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 c73ef28e25..3d3c22ea12 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,6 +21,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
@@ -62,8 +63,10 @@ public class Disconnect extends MessageType {
             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.
+                    // A clean-session disconnect discards all stored session 
state,
+                    // including the inbound packet ids still being 
acknowledged (#7919)
                     
Singleton.INST.get(SessionRepository.class).remove(clientId);
+                    
Singleton.INST.get(InflightRepository.class).remove(clientId);
                 }
             }
         }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/PubRel.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/PubRel.java
index 516ceb96db..023ca1fe67 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/PubRel.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/PubRel.java
@@ -22,6 +22,11 @@ import io.netty.handler.codec.mqtt.MqttFixedHeader;
 import io.netty.handler.codec.mqtt.MqttMessage;
 import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
 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.InflightRepository;
+
+import java.util.Objects;
 
 import static io.netty.handler.codec.mqtt.MqttMessageType.PUBCOMP;
 
@@ -34,6 +39,10 @@ public class PubRel extends MessageType {
     @Override
     public void pubRel(final ChannelHandlerContext ctx, final MqttMessage msg) 
{
         MqttMessageIdVariableHeader variableHeader = 
(MqttMessageIdVariableHeader) msg.variableHeader();
+        String clientId = 
Singleton.INST.get(ChannelRepository.class).get(ctx.channel());
+        if (Objects.nonNull(clientId)) {
+            Singleton.INST.get(InflightRepository.class).release(clientId, 
variableHeader.messageId());
+        }
         MqttFixedHeader mqttFixedHeader = new MqttFixedHeader(PUBCOMP, false, 
MqttQoS.AT_MOST_ONCE, false, 0);
         MqttMessage mqttPubCompMessage = new MqttMessage(mqttFixedHeader, 
variableHeader);
         ctx.writeAndFlush(mqttPubCompMessage);
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Publish.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Publish.java
index e5ad6fc169..5a0482b723 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Publish.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/Publish.java
@@ -31,6 +31,10 @@ import io.netty.handler.codec.mqtt.MqttMessageType;
 import io.netty.handler.codec.mqtt.MqttPublishVariableHeader;
 import io.netty.util.ReferenceCountUtil;
 import org.apache.shenyu.common.utils.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.apache.shenyu.protocol.mqtt.repositories.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.TopicRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.WillRepository;
@@ -49,6 +53,8 @@ import static 
io.netty.handler.codec.mqtt.MqttMessageType.PUBREC;
  */
 public class Publish extends MessageType {
 
+    private static final Logger LOG = LoggerFactory.getLogger(Publish.class);
+
     @Override
     public void publish(final ChannelHandlerContext ctx, final 
MqttPublishMessage msg) {
         if (!isConnected(ctx.channel())) {
@@ -68,25 +74,22 @@ public class Publish extends MessageType {
             }
         }
         int packetId = msg.variableHeader().packetId();
-        // The inbound message is released by MqttTransportHandler once 
publish returns, retain the payload for the asynchronous send.
-        payload.retain();
-        CompletableFuture.runAsync(() -> {
-            try {
-                send(topic, payload, mqttQoS);
-            } finally {
-                ReferenceCountUtil.safeRelease(payload);
-            }
-        });
-
         switch (mqttQoS.value()) {
             case 0:
+                deliverAsync(topic, payload, mqttQoS);
                 break;
 
             case 1:
+                deliverAsync(topic, payload, mqttQoS);
                 qos1(ctx, packetId);
                 break;
 
             case 2:
+                // exactly-once: a retransmitted publish is answered with 
PUBREC
+                // but must not be delivered to subscribers again (#7919)
+                if (registerInbound(ctx.channel(), packetId)) {
+                    deliverAsync(topic, payload, mqttQoS);
+                }
                 qos2(ctx, packetId);
                 break;
             default:
@@ -95,6 +98,44 @@ public class Publish extends MessageType {
 
     }
 
+    /**
+     * Delivers the message to the subscribers of the topic on the common pool.
+     * The inbound message is released by MqttTransportHandler once publish 
returns,
+     * retain the payload for the asynchronous send.
+     *
+     * @param topic the topic of the publish
+     * @param payload the payload of the publish
+     * @param mqttQoS the qos level of the publish
+     */
+    private void deliverAsync(final String topic, final ByteBuf payload, final 
MqttQoS mqttQoS) {
+        payload.retain();
+        CompletableFuture.runAsync(() -> {
+            try {
+                send(topic, payload, mqttQoS);
+            } finally {
+                ReferenceCountUtil.safeRelease(payload);
+            }
+        });
+    }
+
+    /**
+     * Registers the inbound packet for deduplication. QoS 2 packet ids are 
scoped
+     * to the connection, so without a known client the publish is delivered 
as before.
+     *
+     * @param channel the channel of the publisher
+     * @param packetId the packet identifier of the inbound publish
+     * @return true when the packet was seen for the first time
+     */
+    private boolean registerInbound(final Channel channel, final int packetId) 
{
+        String clientId = 
Singleton.INST.get(ChannelRepository.class).get(channel);
+        if (Objects.isNull(clientId)) {
+            // dedup is keyed by the client id: without one, deliver as before
+            LOG.debug("mqtt publish on channel {} has no client id, skipping 
qos2 dedup", channel.id());
+            return true;
+        }
+        return Singleton.INST.get(InflightRepository.class).register(clientId, 
packetId);
+    }
+
     /**
      * send PUBACK to the publisher for a qos1 publish.
      */
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/InflightRepository.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/InflightRepository.java
new file mode 100644
index 0000000000..7eda991208
--- /dev/null
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/main/java/org/apache/shenyu/protocol/mqtt/repositories/InflightRepository.java
@@ -0,0 +1,80 @@
+/*
+ * 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.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Tracks the inbound packet identifiers currently being acknowledged, so that
+ * a retransmitted (DUP) publish can be answered without re-delivering it 
(#7919).
+ * Entries are released by PUBREL, and a new session (clean-session connect or 
no
+ * stored session) discards them, since it resets the packet id space.
+ *
+ * <p>The state is keyed by the client id, not by the channel: session state
+ * belongs to the client id per spec, so during a duplicate-clientId takeover 
the
+ * replacement channel shares — and must keep honoring — the same in-flight 
ids.</p>
+ */
+public class InflightRepository implements BaseRepository<String, 
Set<Integer>> {
+
+    private static final Map<String, Set<Integer>> INBOUND_INFLIGHT = new 
ConcurrentHashMap<>();
+
+    @Override
+    public void add(final String clientId, final Set<Integer> packetIds) {
+        INBOUND_INFLIGHT.put(clientId, packetIds);
+    }
+
+    @Override
+    public void remove(final String clientId) {
+        INBOUND_INFLIGHT.remove(clientId);
+    }
+
+    @Override
+    public Set<Integer> get(final String clientId) {
+        return INBOUND_INFLIGHT.get(clientId);
+    }
+
+    /**
+     * Registers a packet identifier as being acknowledged.
+     *
+     * @param clientId the client that published the packet
+     * @param packetId the packet identifier of the inbound publish
+     * @return true when the packet was seen for the first time, false on a 
retransmission
+     */
+    public boolean register(final String clientId, final int packetId) {
+        return INBOUND_INFLIGHT.computeIfAbsent(clientId, key -> 
ConcurrentHashMap.newKeySet()).add(packetId);
+    }
+
+    /**
+     * Releases the packet identifier once the client confirms it with PUBREL.
+     * The client entry itself is dropped when its last in-flight id is 
released,
+     * so ids that never publish again do not linger for the lifetime of the 
JVM.
+     *
+     * @param clientId the client that published the packet
+     * @param packetId the packet identifier to release
+     */
+    public void release(final String clientId, final int packetId) {
+        Set<Integer> packetIds = INBOUND_INFLIGHT.get(clientId);
+        if (Objects.nonNull(packetIds) && packetIds.remove(packetId) && 
packetIds.isEmpty()) {
+            // two-arg remove: a concurrent register must not be evicted
+            INBOUND_INFLIGHT.remove(clientId, packetIds);
+        }
+    }
+}
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
index 45af4a5ece..9971d57f0c 100644
--- 
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
@@ -30,6 +30,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.WillRepository;
@@ -86,6 +87,8 @@ public final class ClientIdTakeoverTest {
         Singleton.INST.single(SessionRepository.class, sessionRepository);
         Singleton.INST.single(WillRepository.class, willRepository);
         Singleton.INST.single(SubscribeRepository.class, subscribeRepository);
+        // Connect reaches this repository on a new session, and Singleton.get 
does not create one
+        Singleton.INST.single(InflightRepository.class, new 
InflightRepository());
         MqttContext mqttContext = new MqttContext();
         mqttContext.setUserName(USER_NAME);
         mqttContext.setPassword(PASSWORD);
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
index c2b57536ba..a98e4b6859 100644
--- 
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
@@ -34,6 +34,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
@@ -75,6 +76,7 @@ public class ConnectSessionPresentTest {
         Singleton.INST.single(ChannelRepository.class, new 
ChannelRepository());
         Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
         Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
+        Singleton.INST.single(InflightRepository.class, new 
InflightRepository());
         MqttContext mqttContext = new MqttContext();
         mqttContext.setUserName("shenyu");
         mqttContext.setPassword("shenyu");
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectTest.java
index 011b759892..caf31afb35 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectTest.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/ConnectTest.java
@@ -30,6 +30,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.WillRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.WillRepository.WillEntry;
 import org.junit.jupiter.api.AfterAll;
@@ -95,6 +96,7 @@ public final class ConnectTest {
         willRepository = new WillRepository();
         Singleton.INST.single(ChannelRepository.class, channelRepository);
         Singleton.INST.single(WillRepository.class, willRepository);
+        Singleton.INST.single(InflightRepository.class, new 
InflightRepository());
     }
 
     @AfterEach
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 c58a59ac96..ad059d8ce3 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
@@ -23,6 +23,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.MqttSession;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
@@ -55,6 +56,7 @@ public class DisconnectDispatchTest {
         Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
         Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
         Singleton.INST.single(WillRepository.class, new WillRepository());
+        Singleton.INST.single(InflightRepository.class, new 
InflightRepository());
         ctx = mock(ChannelHandlerContext.class);
         channel = mock(Channel.class);
         when(ctx.channel()).thenReturn(channel);
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 f28ff11057..2976e02a85 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,7 @@ 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.WillRepository;
@@ -79,6 +80,8 @@ public final class MqttFactoryTest {
         Singleton.INST.single(WillRepository.class, willRepository);
         Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
         Singleton.INST.single(SubscribeRepository.class, new 
SubscribeRepository());
+        // Connect reaches this repository on a new session, and Singleton.get 
does not create one
+        Singleton.INST.single(InflightRepository.class, new 
InflightRepository());
         new MqttContext().setUserName(USER_NAME);
         new MqttContext().setPassword(PASSWORD);
     }
diff --git 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PubRelTest.java
 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PubRelTest.java
index 1acd34c6e4..297e9e77ad 100644
--- 
a/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PubRelTest.java
+++ 
b/shenyu-protocol/shenyu-protocol-mqtt/src/test/java/org/apache/shenyu/protocol/mqtt/PubRelTest.java
@@ -17,18 +17,23 @@
 
 package org.apache.shenyu.protocol.mqtt;
 
+import io.netty.channel.Channel;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.handler.codec.mqtt.MqttFixedHeader;
 import io.netty.handler.codec.mqtt.MqttMessage;
 import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
 import io.netty.handler.codec.mqtt.MqttMessageType;
 import io.netty.handler.codec.mqtt.MqttQoS;
+import org.apache.shenyu.common.utils.Singleton;
+import org.apache.shenyu.protocol.mqtt.repositories.ChannelRepository;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /**
  * Test cases for PubRel.
@@ -39,6 +44,12 @@ public class PubRelTest {
 
     private final ChannelHandlerContext ctx = 
mock(ChannelHandlerContext.class);
 
+    @BeforeEach
+    public void setUp() {
+        Singleton.INST.single(ChannelRepository.class, new 
ChannelRepository());
+        when(ctx.channel()).thenReturn(mock(Channel.class));
+    }
+
     @Test
     public void testPubRelRespondsWithPubComp() {
         MqttFixedHeader pubRelFixedHeader = new 
MqttFixedHeader(MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0);
@@ -52,4 +63,18 @@ public class PubRelTest {
         assertEquals(MqttQoS.AT_MOST_ONCE, pubComp.fixedHeader().qosLevel());
         assertEquals(PACKET_ID, ((MqttMessageIdVariableHeader) 
pubComp.variableHeader()).messageId());
     }
+
+    /**
+     * A PUBREL for an unknown packet id is still answered with PUBCOMP 
(#7919).
+     */
+    @Test
+    public void testPubRelForUnknownPacketIdStillRespondsWithPubComp() {
+        MqttFixedHeader pubRelFixedHeader = new 
MqttFixedHeader(MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0);
+        MqttMessage pubRel = new MqttMessage(pubRelFixedHeader, 
MqttMessageIdVariableHeader.from(PACKET_ID));
+        new PubRel().pubRel(ctx, pubRel);
+
+        ArgumentCaptor<MqttMessage> captor = 
ArgumentCaptor.forClass(MqttMessage.class);
+        verify(ctx).writeAndFlush(captor.capture());
+        assertEquals(MqttMessageType.PUBCOMP, 
captor.getValue().fixedHeader().messageType());
+    }
 }
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 42f72bd51a..adb9a2894b 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
@@ -42,6 +42,7 @@ import io.netty.util.CharsetUtil;
 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.InflightRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SubscribeRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.SessionRepository;
 import org.apache.shenyu.protocol.mqtt.repositories.TopicRepository;
@@ -67,6 +68,7 @@ import static org.awaitility.Awaitility.await;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 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;
 import static org.mockito.Mockito.mock;
@@ -106,6 +108,10 @@ public final class PublishTest {
 
     private static final int PUBLISHER_PACKET_ID = 12345;
 
+    private static final int RETRANSMIT_PACKET_ID = 77;
+
+    private static final int BARRIER_PACKET_ID = 78;
+
     private static final Duration TIMEOUT = Duration.ofSeconds(5);
 
     private static final Duration POLL_INTERVAL = Duration.ofMillis(10);
@@ -132,6 +138,7 @@ public final class PublishTest {
         Singleton.INST.single(TopicRepository.class, TOPIC_REPOSITORY);
         Singleton.INST.single(ChannelRepository.class, CHANNEL_REPOSITORY);
         Singleton.INST.single(SessionRepository.class, new 
SessionRepository());
+        Singleton.INST.single(InflightRepository.class, new 
InflightRepository());
         new MqttContext().setUserName(USER_NAME);
         new MqttContext().setPassword(PASSWORD);
 
@@ -146,6 +153,7 @@ public final class PublishTest {
     public void tearDown() {
         MqttPacketIdGenerator.remove(subscriberChannel);
         MqttPacketIdGenerator.remove(otherSubscriberChannel);
+        Singleton.INST.get(InflightRepository.class).remove(CLIENT_ID);
         publisherChannel.finishAndReleaseAll();
         clearSharedState();
 
@@ -286,7 +294,8 @@ public final class PublishTest {
         captureMessages(subscriberChannel, 1);
         captureMessages(otherSubscriberChannel, 1);
 
-        publishToSubscribers(MqttQoS.EXACTLY_ONCE);
+        // a conforming client never reuses a packet id before its 
acknowledgement
+        publishToSubscribers(MqttQoS.EXACTLY_ONCE, PUBLISHER_PACKET_ID + 1, 
false);
 
         List<MqttPublishMessage> messages = captureMessages(subscriberChannel, 
2);
         assertEquals(1, messages.get(0).variableHeader().packetId());
@@ -339,6 +348,86 @@ public final class PublishTest {
         assertEquals(PUBLISHER_PACKET_ID, ((MqttMessageIdVariableHeader) 
pubRec.variableHeader()).messageId());
     }
 
+    @Test
+    public void testQos2RetransmitIsPublishedExactlyOnce() {
+        CHANNEL_REPOSITORY.add(publisherChannel, CLIENT_ID);
+        addSubscriber(subscriberChannel, MqttQoS.EXACTLY_ONCE);
+        try {
+            publishToSubscribers(MqttQoS.EXACTLY_ONCE, RETRANSMIT_PACKET_ID, 
false);
+            captureMessages(subscriberChannel, 1);
+            assertPubRec(RETRANSMIT_PACKET_ID);
+
+            // the retransmission is answered, but must not be delivered again 
(#7919)
+            publishToSubscribers(MqttQoS.EXACTLY_ONCE, RETRANSMIT_PACKET_ID, 
true);
+            assertPubRec(RETRANSMIT_PACKET_ID);
+
+            // ordering barrier: a fan-out happens on the common pool, so the 
next publish
+            // would queue behind it — if the retransmission had been 
delivered, the count
+            // here could only be 3, and times(2) fails deterministically
+            publishToSubscribers(MqttQoS.EXACTLY_ONCE, BARRIER_PACKET_ID, 
false);
+            assertPubRec(BARRIER_PACKET_ID);
+            captureMessages(subscriberChannel, 2);
+        } finally {
+            CHANNEL_REPOSITORY.remove(publisherChannel);
+        }
+    }
+
+    /**
+     * A new session (clean-session reconnect) resets the packet id space, so a
+     * stale in-flight id must not suppress a publish of the same id (#7919).
+     */
+    @Test
+    public void testCleanSessionReconnectClearsInflightIds() {
+        CHANNEL_REPOSITORY.add(publisherChannel, CLIENT_ID);
+        addSubscriber(subscriberChannel, MqttQoS.EXACTLY_ONCE);
+        final EmbeddedChannel reconnected = channel(false);
+        CHANNEL_REPOSITORY.add(reconnected, CLIENT_ID);
+        try {
+            publishToSubscribers(MqttQoS.EXACTLY_ONCE, RETRANSMIT_PACKET_ID, 
false);
+            captureMessages(subscriberChannel, 1);
+            assertPubRec(RETRANSMIT_PACKET_ID);
+
+            // reconnect with cleanSession=1: a new session starts, the packet 
id space resets
+            new Connect().connect(reconnected.pipeline().lastContext(), 
connectMessage());
+            assertNotNull(reconnected.readOutbound());
+
+            // the same packet id is legal again on the new session and must 
be delivered
+            final MqttFixedHeader fixedHeader = new 
MqttFixedHeader(MqttMessageType.PUBLISH, true, MqttQoS.EXACTLY_ONCE, false, 0);
+            final MqttPublishVariableHeader variableHeader = new 
MqttPublishVariableHeader(TOPIC, RETRANSMIT_PACKET_ID);
+            new Publish().publish(reconnected.pipeline().lastContext(),
+                    new MqttPublishMessage(fixedHeader, variableHeader, 
Unpooled.copiedBuffer(PAYLOAD, CharsetUtil.UTF_8)));
+            final MqttMessage pubRec = reconnected.readOutbound();
+            assertEquals(MqttMessageType.PUBREC, 
pubRec.fixedHeader().messageType());
+            captureMessages(subscriberChannel, 2);
+        } finally {
+            CHANNEL_REPOSITORY.remove(publisherChannel);
+            CHANNEL_REPOSITORY.remove(reconnected);
+            reconnected.finishAndReleaseAll();
+        }
+    }
+
+    @Test
+    public void testPubRelReleasesInflightForRedelivery() {
+        CHANNEL_REPOSITORY.add(publisherChannel, CLIENT_ID);
+        addSubscriber(subscriberChannel, MqttQoS.EXACTLY_ONCE);
+        try {
+            publishToSubscribers(MqttQoS.EXACTLY_ONCE, RETRANSMIT_PACKET_ID, 
false);
+            captureMessages(subscriberChannel, 1);
+            assertPubRec(RETRANSMIT_PACKET_ID);
+
+            new PubRel().pubRel(publisherContext(), 
pubRelMessage(RETRANSMIT_PACKET_ID));
+            MqttMessage pubComp = awaitOutbound(publisherChannel);
+            assertEquals(MqttMessageType.PUBCOMP, 
pubComp.fixedHeader().messageType());
+
+            // the packet id is free again, so the next publish is delivered
+            publishToSubscribers(MqttQoS.EXACTLY_ONCE, RETRANSMIT_PACKET_ID, 
true);
+            captureMessages(subscriberChannel, 2);
+            assertPubRec(RETRANSMIT_PACKET_ID);
+        } finally {
+            CHANNEL_REPOSITORY.remove(publisherChannel);
+        }
+    }
+
     private static Stream<Arguments> retainedPayloads() {
         byte[] text = "hello 中文".getBytes(StandardCharsets.UTF_8);
         byte[] binary = {0, (byte) 0xff, (byte) 0x80, 0x41};
@@ -411,6 +500,24 @@ public final class PublishTest {
         new Publish().publish(publisherContext(), new 
MqttPublishMessage(fixedHeader, variableHeader, payload));
     }
 
+    private void publishToSubscribers(final MqttQoS qos, final int packetId, 
final boolean dup) {
+        final MqttFixedHeader fixedHeader = new 
MqttFixedHeader(MqttMessageType.PUBLISH, dup, qos, false, 0);
+        final MqttPublishVariableHeader variableHeader = new 
MqttPublishVariableHeader(TOPIC, packetId);
+        new Publish().publish(publisherContext(),
+                new MqttPublishMessage(fixedHeader, variableHeader, 
Unpooled.copiedBuffer(PAYLOAD, CharsetUtil.UTF_8)));
+    }
+
+    private void assertPubRec(final int packetId) {
+        final MqttMessage pubRec = awaitOutbound(publisherChannel);
+        assertEquals(MqttMessageType.PUBREC, 
pubRec.fixedHeader().messageType());
+        assertEquals(packetId, ((MqttMessageIdVariableHeader) 
pubRec.variableHeader()).messageId());
+    }
+
+    private MqttMessage pubRelMessage(final int packetId) {
+        return new MqttMessage(new MqttFixedHeader(MqttMessageType.PUBREL, 
false, MqttQoS.AT_MOST_ONCE, false, 0),
+                MqttMessageIdVariableHeader.from(packetId));
+    }
+
     private MqttPublishMessage captureMessage(final Channel channel) {
         return captureMessages(channel, 1).get(0);
     }

Reply via email to