Client: Tolerate late acknowledgement packets (#2078, #2079) (#2247)

When a publish, subscribe or unsubscribe request was cancelled or
timed out client-side, the corresponding awaitable was removed from
the packet dispatcher. A subsequent PUBACK / PUBCOMP / SUBACK /
UNSUBACK arriving from the broker then fell through to the default
case and raised an MqttProtocolViolationException, which forced a
disconnect. Under high publish load (issue #2079) and from multiple
threads (issue #2078) this manifested as sporadic connection drops.

Match these acknowledgement packets explicitly and log a warning
when no awaiter is registered, instead of treating it as a protocol
violation. The connection is now preserved.

Also documents the thread-safety guarantees of IMqttClient: publish,
subscribe, unsubscribe and ping operations are safe to invoke
concurrently. Connect / disconnect must still be coordinated.

Co-authored-by: meisa <meisa@MAFDEL>
Co-authored-by: christian <6939810+chkr1011@users.noreply.github.com>
This commit is contained in:
Meisam Alifallhi
2026-06-16 20:28:40 +02:00
committed by GitHub
co-authored by meisa christian
parent 745d1d25fe
commit c1b46b0ae7
4 changed files with 137 additions and 1 deletions
@@ -870,4 +870,109 @@ public sealed class MqttClient_Tests : BaseTestClass
Assert.IsTrue(client1.TryPingAsync().GetAwaiter().GetResult());
Assert.IsFalse(disconnectedFired);
}
// Regression test for issue #2079.
// A late PUBACK (one whose matching publish request was already cancelled or timed
// out client-side) must NOT raise a protocol violation and must NOT disconnect the
// client. Before the fix, the client received the late PUBACK, failed to find a
// waiter and threw a MqttProtocolViolationException, tearing down the connection.
[TestMethod]
[DataRow(MqttQualityOfServiceLevel.AtLeastOnce)]
[DataRow(MqttQualityOfServiceLevel.ExactlyOnce)]
public async Task Cancelled_Publish_Does_Not_Disconnect_Client(MqttQualityOfServiceLevel qos)
{
using var testEnvironment = new TestEnvironment(TestContext);
var server = await testEnvironment.StartServer();
// Delay the broker's acknowledgement long enough for the publish caller to give
// up. When the caller's cancellation token fires the awaitable is removed; the
// (slightly later) PUBACK / PUBREC then arrives without a matching waiter.
server.InterceptingPublishAsync += async e =>
{
await Task.Delay(TimeSpan.FromSeconds(2), CancellationToken.None);
};
var client = await testEnvironment.ConnectClient();
var disconnected = false;
client.DisconnectedAsync += _ =>
{
disconnected = true;
return CompletedTask.Instance;
};
var message = new MqttApplicationMessageBuilder().WithTopic("late-ack").WithQualityOfServiceLevel(qos).Build();
using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)))
{
await Assert.ThrowsExactlyAsync<MqttCommunicationTimedOutException>(() => client.PublishAsync(message, cts.Token));
}
// Wait long enough for the delayed broker acknowledgement to come back.
await Task.Delay(TimeSpan.FromSeconds(3));
Assert.IsFalse(disconnected, "Client must not disconnect when a late acknowledgement arrives.");
Assert.IsTrue(client.IsConnected, "Client must still be connected after a late acknowledgement.");
// The connection must remain usable for further publishes.
var followUp = new MqttApplicationMessageBuilder().WithTopic("late-ack").WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtMostOnce).Build();
await client.PublishAsync(followUp);
}
// Regression test for issue #2078.
// The MqttClient must support concurrent publishes from multiple threads without
// dropping messages, mismatching acknowledgements or disconnecting the client.
[TestMethod]
[DataRow(MqttQualityOfServiceLevel.AtMostOnce)]
[DataRow(MqttQualityOfServiceLevel.AtLeastOnce)]
[DataRow(MqttQualityOfServiceLevel.ExactlyOnce)]
public async Task Concurrent_Publish_From_Multiple_Threads(MqttQualityOfServiceLevel qos)
{
const int Threads = 8;
const int MessagesPerThread = 250;
using var testEnvironment = new TestEnvironment(TestContext);
await testEnvironment.StartServer();
var publisher = await testEnvironment.ConnectClient();
var subscriber = await testEnvironment.ConnectClient();
var receivedCount = 0;
subscriber.ApplicationMessageReceivedAsync += _ =>
{
Interlocked.Increment(ref receivedCount);
return CompletedTask.Instance;
};
await subscriber.SubscribeAsync("concurrent/#", qos);
var disconnected = false;
publisher.DisconnectedAsync += _ =>
{
disconnected = true;
return CompletedTask.Instance;
};
var tasks = Enumerable.Range(0, Threads).Select(threadIndex => Task.Run(async () =>
{
for (var i = 0; i < MessagesPerThread; i++)
{
var message = new MqttApplicationMessageBuilder()
.WithTopic($"concurrent/{threadIndex}/{i}")
.WithPayload(BitConverter.GetBytes(i))
.WithQualityOfServiceLevel(qos)
.Build();
await publisher.PublishAsync(message);
}
})).ToArray();
await Task.WhenAll(tasks);
// Allow the broker to deliver any in-flight messages to the subscriber.
SpinWait.SpinUntil(() => receivedCount >= Threads * MessagesPerThread, TimeSpan.FromSeconds(30));
Assert.IsFalse(disconnected, "Publisher must not disconnect during concurrent publishes.");
Assert.IsTrue(publisher.IsConnected);
Assert.AreEqual(Threads * MessagesPerThread, receivedCount);
}
}