Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions Source/MQTTnet.Tests/Clients/MqttClient/MqttClient_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -870,4 +870,109 @@ public async Task Subscribe_With_QoS2()
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);
}
}
15 changes: 15 additions & 0 deletions Source/MQTTnet/IMqttClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

namespace MQTTnet;

/// <summary>
/// Represents an MQTT client.
/// <para>
/// Instances of <see cref="IMqttClient" /> are safe for concurrent use: <see cref="PublishAsync" />,
/// <see cref="SubscribeAsync" />, <see cref="UnsubscribeAsync" /> and <see cref="PingAsync" /> may be
/// invoked from multiple threads at the same time. Outgoing packets are serialized internally so
/// that they reach the network in well-formed order, and incoming acknowledgements are routed back
/// to the corresponding caller via the packet identifier.
/// </para>
/// <para>
/// <see cref="ConnectAsync" /> and <see cref="DisconnectAsync" /> must not be called concurrently
/// with each other or with publish/subscribe operations. Coordinate connection lifecycle changes on
/// a single thread.
/// </para>
/// </summary>
public interface IMqttClient : IDisposable
{
event Func<MqttApplicationMessageReceivedEventArgs, Task> ApplicationMessageReceivedAsync;
Expand Down
15 changes: 15 additions & 0 deletions Source/MQTTnet/MqttClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,21 @@ async Task TryProcessReceivedPacket(MqttPacket packet, CancellationToken cancell
{
case MqttPublishPacket publishPacket:
EnqueueReceivedPublishPacket(publishPacket);
break;
case MqttPubAckPacket _:
case MqttPubCompPacket _:
case MqttSubAckPacket _:
case MqttUnsubAckPacket _:
// Acknowledgement packets are dispatched to the awaiter that issued the
// matching request. If no awaiter is registered (for example because the
// request was cancelled or timed out client-side before the broker replied)
// the late acknowledgement is harmless and must NOT terminate the connection.
// See issues #2078 and #2079.
if (!_packetDispatcher.TryDispatch(packet))
{
_logger.Warning("Received {0} for an unknown packet identifier. Probably the matching request was cancelled or timed out.", packet.GetType().Name);
}

break;
case MqttPubRecPacket pubRecPacket:
await ProcessReceivedPubRecPacket(pubRecPacket, cancellationToken).ConfigureAwait(false);
Expand Down
3 changes: 2 additions & 1 deletion Source/ReleaseNotes.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
- Server: Fixed exposinbg Topic Alias to clients (#2250, thanks to @suhashollakc)
* Client: Late acknowledgement packets (PUBACK, PUBCOMP, SUBACK, UNSUBACK) for cancelled or timed-out requests no longer trigger a protocol violation and tear down the connection (#2078, #2079)
* Server: Fixed exposing Topic Alias to clients (#2250, thanks to @suhashollakc)
Loading