feat(mqtt): implement QoS 1 PUBACK and QoS 2 PUBREC/PUBREL/PUBCOMP handlers
QoS 1: deliver message then send PUBACK. QoS 2: store in-memory pending PUBREL, send PUBREC; on PUBREL deliver and send PUBCOMP. Wire all four PI-packet dispatches (PUBACK/PUBREC/PUBREL/PUBCOMP) in parser. Add QoS2Pending dictionary to MqttHandler for in-memory QoS 2 tracking. 6 new tests for QoS 1/2 flows including full QoS 2 handshake.
This commit is contained in:
@@ -91,6 +91,16 @@ internal sealed class MqttHandler
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool DowngradeQoS2Sub { get; set; }
|
public bool DowngradeQoS2Sub { get; set; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// QoS 2 in-memory pending store (full JetStream persistence in Task 6)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory store for QoS 2 inbound messages awaiting PUBREL.
|
||||||
|
/// Keyed by packet identifier. Replaces JetStream $MQTT_qos2in stream.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<ushort, MqttPublishInfo> QoS2Pending { get; } = new();
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Parse state (used by the read-loop MQTT byte-stream parser)
|
// Parse state (used by the read-loop MQTT byte-stream parser)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -313,21 +313,146 @@ internal static class MqttPacketHandlers
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Processes an inbound MQTT PUBLISH packet.
|
/// Processes an inbound MQTT PUBLISH packet.
|
||||||
/// QoS 0: routes immediately via NATS pub/sub. QoS 1/2: deferred to Task 5.
|
/// QoS 0: routes immediately. QoS 1: deliver + PUBACK. QoS 2: store + PUBREC.
|
||||||
/// Mirrors Go <c>mqttProcessPub()</c>.
|
/// Mirrors Go <c>mqttProcessPub()</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static Exception? ProcessPub(ClientConnection c, MqttPublishInfo pp)
|
public static Exception? ProcessPub(ClientConnection c, MqttPublishInfo pp)
|
||||||
{
|
{
|
||||||
if (pp.Qos == 0)
|
var payload = pp.Msg ?? [];
|
||||||
|
|
||||||
|
switch (pp.Qos)
|
||||||
{
|
{
|
||||||
// QoS 0: immediate delivery via internal routing.
|
case 0:
|
||||||
var payload = pp.Msg ?? [];
|
c.ProcessInboundClientMsg(payload);
|
||||||
c.ProcessInboundClientMsg(payload);
|
return null;
|
||||||
return null;
|
|
||||||
|
case 1:
|
||||||
|
// QoS 1: deliver immediately, then send PUBACK.
|
||||||
|
c.ProcessInboundClientMsg(payload);
|
||||||
|
EnqueuePubResponse(c, MqttPacket.PubAck, pp.Pi);
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
// QoS 2: store message pending PUBREL, then send PUBREC.
|
||||||
|
// Full JetStream persistence deferred to Task 6 — uses in-memory store.
|
||||||
|
lock (c)
|
||||||
|
{
|
||||||
|
c.Mqtt!.QoS2Pending[pp.Pi] = pp;
|
||||||
|
}
|
||||||
|
EnqueuePubResponse(c, MqttPacket.PubRec, pp.Pi);
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new InvalidOperationException($"invalid QoS: {pp.Qos}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// PUBACK / PUBREC / PUBREL / PUBCOMP handling
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a packet-identifier-only packet (PUBACK, PUBREC, PUBREL, PUBCOMP).
|
||||||
|
/// Returns (pi, error). Mirrors Go <c>mqttParsePIPacket()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static (ushort pi, Exception? err) ParsePiPacket(MqttReader r)
|
||||||
|
{
|
||||||
|
ushort pi;
|
||||||
|
try { pi = r.ReadUInt16("packet identifier"); }
|
||||||
|
catch (Exception ex) { return (0, ex); }
|
||||||
|
if (pi == 0)
|
||||||
|
return (0, new InvalidOperationException("packet identifier must not be 0"));
|
||||||
|
return (pi, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes an inbound PUBREL packet (QoS 2 phase 2).
|
||||||
|
/// Retrieves the stored QoS 2 message, delivers it, and sends PUBCOMP.
|
||||||
|
/// Mirrors Go <c>mqttProcessPubRel()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static Exception? ProcessPubRel(ClientConnection c, ushort pi)
|
||||||
|
{
|
||||||
|
// Always send PUBCOMP, even if message not found (idempotency).
|
||||||
|
MqttPublishInfo? pp;
|
||||||
|
lock (c)
|
||||||
|
{
|
||||||
|
c.Mqtt!.QoS2Pending.Remove(pi, out pp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// QoS 1/2: deferred to Task 5 (JetStream integration).
|
// Send PUBCOMP.
|
||||||
return new NotImplementedException($"PUBLISH QoS {pp.Qos} not yet implemented");
|
EnqueuePubResponse(c, MqttPacket.PubComp, pi);
|
||||||
|
|
||||||
|
// Deliver the stored message if found.
|
||||||
|
if (pp != null)
|
||||||
|
{
|
||||||
|
var payload = pp.Msg ?? [];
|
||||||
|
c.ProcessInboundClientMsg(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes an inbound PUBACK (client acknowledges QoS 1 message from server).
|
||||||
|
/// Removes from pending tracking. Mirrors Go <c>mqttProcessPubAck()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static Exception? ProcessPubAck(ClientConnection c, ushort pi)
|
||||||
|
{
|
||||||
|
lock (c)
|
||||||
|
{
|
||||||
|
c.Mqtt!.Pending.Remove(pi);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes an inbound PUBREC (client acknowledges QoS 2 message from server, phase 1).
|
||||||
|
/// Transitions to PUBREL phase. Mirrors Go <c>mqttProcessPubRec()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static Exception? ProcessPubRec(ClientConnection c, ushort pi)
|
||||||
|
{
|
||||||
|
// In the full implementation, this would store a PUBREL in JetStream.
|
||||||
|
// For now, send PUBREL immediately and remove from pending.
|
||||||
|
lock (c)
|
||||||
|
{
|
||||||
|
c.Mqtt!.Pending.Remove(pi);
|
||||||
|
}
|
||||||
|
EnqueuePubResponse(c, MqttPacket.PubRel, pi);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes an inbound PUBCOMP (client acknowledges PUBREL, QoS 2 complete).
|
||||||
|
/// Mirrors Go <c>mqttProcessPubComp()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static Exception? ProcessPubComp(ClientConnection c, ushort pi)
|
||||||
|
{
|
||||||
|
// Final cleanup — remove any tracking for this PI.
|
||||||
|
lock (c)
|
||||||
|
{
|
||||||
|
c.Mqtt!.Pending.Remove(pi);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enqueues a PUBACK/PUBREC/PUBREL/PUBCOMP response packet.
|
||||||
|
/// All four share the same 4-byte format: [type] [0x02] [PI high] [PI low].
|
||||||
|
/// PUBREL has bit 1 set in byte 0 per MQTT spec [3.6.1-1].
|
||||||
|
/// Mirrors Go <c>mqttEnqueuePubResponse()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnqueuePubResponse(ClientConnection c, byte packetType, ushort pi)
|
||||||
|
{
|
||||||
|
var b0 = packetType;
|
||||||
|
// PUBREL requires fixed header bits 0010 per MQTT spec.
|
||||||
|
if (packetType == MqttPacket.PubRel)
|
||||||
|
b0 |= 0x02;
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> packet = [b0, 0x02, (byte)(pi >> 8), (byte)(pi & 0xFF)];
|
||||||
|
lock (c)
|
||||||
|
{
|
||||||
|
c.EnqueueProto(packet);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -92,24 +92,36 @@ internal static class MqttParser
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MqttPacket.PubAck:
|
case MqttPacket.PubAck:
|
||||||
// TODO: Task 5 — process PUBACK
|
{
|
||||||
err = new NotImplementedException("PUBACK not yet implemented");
|
var (ackPi, ackErr) = MqttPacketHandlers.ParsePiPacket(r);
|
||||||
|
if (ackErr != null) { err = ackErr; break; }
|
||||||
|
err = MqttPacketHandlers.ProcessPubAck(c, ackPi);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case MqttPacket.PubRec:
|
case MqttPacket.PubRec:
|
||||||
// TODO: Task 5 — process PUBREC
|
{
|
||||||
err = new NotImplementedException("PUBREC not yet implemented");
|
var (recPi, recErr) = MqttPacketHandlers.ParsePiPacket(r);
|
||||||
|
if (recErr != null) { err = recErr; break; }
|
||||||
|
err = MqttPacketHandlers.ProcessPubRec(c, recPi);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case MqttPacket.PubRel:
|
case MqttPacket.PubRel:
|
||||||
// TODO: Task 5 — process PUBREL
|
{
|
||||||
err = new NotImplementedException("PUBREL not yet implemented");
|
var (relPi, relErr) = MqttPacketHandlers.ParsePiPacket(r);
|
||||||
|
if (relErr != null) { err = relErr; break; }
|
||||||
|
err = MqttPacketHandlers.ProcessPubRel(c, relPi);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case MqttPacket.PubComp:
|
case MqttPacket.PubComp:
|
||||||
// TODO: Task 5 — process PUBCOMP
|
{
|
||||||
err = new NotImplementedException("PUBCOMP not yet implemented");
|
var (compPi, compErr) = MqttPacketHandlers.ParsePiPacket(r);
|
||||||
|
if (compErr != null) { err = compErr; break; }
|
||||||
|
err = MqttPacketHandlers.ProcessPubComp(c, compPi);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case MqttPacket.Sub:
|
case MqttPacket.Sub:
|
||||||
var (subPi, subFilters, subErr) = MqttPacketHandlers.ParseSubsOrUnsubs(r, b, pl, isSub: true);
|
var (subPi, subFilters, subErr) = MqttPacketHandlers.ParseSubsOrUnsubs(r, b, pl, isSub: true);
|
||||||
|
|||||||
@@ -174,14 +174,14 @@ public sealed class MqttPubSubTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Parser_PublishQoS1_ShouldReturnNotImplemented()
|
public void Parser_PublishQoS1_ShouldSendPubAck()
|
||||||
{
|
{
|
||||||
var c = CreateConnectedMqttClient();
|
var c = CreateConnectedMqttClient();
|
||||||
|
|
||||||
// PUBLISH QoS 1: type=0x32, topic="t", PI=1, payload="x"
|
// PUBLISH QoS 1: type=0x32, topic="t", PI=5, payload="x"
|
||||||
var data = new List<byte>();
|
var data = new List<byte>();
|
||||||
data.Add(0x00); data.Add(0x01); data.Add((byte)'t'); // topic
|
data.Add(0x00); data.Add(0x01); data.Add((byte)'t'); // topic
|
||||||
data.Add(0x00); data.Add(0x01); // PI = 1
|
data.Add(0x00); data.Add(0x05); // PI = 5
|
||||||
data.Add((byte)'x'); // payload
|
data.Add((byte)'x'); // payload
|
||||||
|
|
||||||
var buf = new List<byte>();
|
var buf = new List<byte>();
|
||||||
@@ -190,8 +190,146 @@ public sealed class MqttPubSubTests
|
|||||||
buf.AddRange(data);
|
buf.AddRange(data);
|
||||||
|
|
||||||
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
|
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
|
||||||
err.ShouldNotBeNull();
|
err.ShouldBeNull();
|
||||||
err.ShouldBeOfType<NotImplementedException>();
|
|
||||||
|
// Verify PUBACK: [0x40] [0x02] [PI high] [PI low]
|
||||||
|
var ms = GetStream(c);
|
||||||
|
var written = ms.ToArray();
|
||||||
|
written.Length.ShouldBe(4);
|
||||||
|
written[0].ShouldBe(MqttPacket.PubAck); // 0x40
|
||||||
|
written[1].ShouldBe((byte)0x02);
|
||||||
|
written[2].ShouldBe((byte)0x00); // PI high
|
||||||
|
written[3].ShouldBe((byte)0x05); // PI low
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_PublishQoS2_ShouldSendPubRec()
|
||||||
|
{
|
||||||
|
var c = CreateConnectedMqttClient();
|
||||||
|
|
||||||
|
// PUBLISH QoS 2: type=0x34, topic="t", PI=10, payload="y"
|
||||||
|
var data = new List<byte>();
|
||||||
|
data.Add(0x00); data.Add(0x01); data.Add((byte)'t'); // topic
|
||||||
|
data.Add(0x00); data.Add(0x0A); // PI = 10
|
||||||
|
data.Add((byte)'y'); // payload
|
||||||
|
|
||||||
|
var buf = new List<byte>();
|
||||||
|
buf.Add((byte)(MqttPacket.Pub | MqttPubFlag.QoS2)); // 0x34
|
||||||
|
buf.Add((byte)data.Count);
|
||||||
|
buf.AddRange(data);
|
||||||
|
|
||||||
|
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
|
||||||
|
// Verify PUBREC: [0x50] [0x02] [PI high] [PI low]
|
||||||
|
var ms = GetStream(c);
|
||||||
|
var written = ms.ToArray();
|
||||||
|
written.Length.ShouldBe(4);
|
||||||
|
written[0].ShouldBe(MqttPacket.PubRec); // 0x50
|
||||||
|
written[1].ShouldBe((byte)0x02);
|
||||||
|
written[2].ShouldBe((byte)0x00); // PI high
|
||||||
|
written[3].ShouldBe((byte)0x0A); // PI low
|
||||||
|
|
||||||
|
// Message should be stored in QoS2Pending.
|
||||||
|
c.Mqtt!.QoS2Pending.ShouldContainKey((ushort)10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_QoS2_FullHandshake_PubRecPubRelPubComp()
|
||||||
|
{
|
||||||
|
var c = CreateConnectedMqttClient();
|
||||||
|
|
||||||
|
// Step 1: PUBLISH QoS 2 → PUBREC
|
||||||
|
var pubData = new List<byte>();
|
||||||
|
pubData.Add(0x00); pubData.Add(0x01); pubData.Add((byte)'t');
|
||||||
|
pubData.Add(0x00); pubData.Add(0x07); // PI = 7
|
||||||
|
pubData.AddRange(Encoding.UTF8.GetBytes("qos2msg"));
|
||||||
|
|
||||||
|
var pubBuf = new List<byte>();
|
||||||
|
pubBuf.Add((byte)(MqttPacket.Pub | MqttPubFlag.QoS2));
|
||||||
|
pubBuf.Add((byte)pubData.Count);
|
||||||
|
pubBuf.AddRange(pubData);
|
||||||
|
|
||||||
|
var err = MqttParser.Parse(c, pubBuf.ToArray(), pubBuf.Count);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
c.Mqtt!.QoS2Pending.ShouldContainKey((ushort)7);
|
||||||
|
|
||||||
|
// Reset stream to capture only PUBCOMP.
|
||||||
|
var ms = GetStream(c);
|
||||||
|
ms.SetLength(0);
|
||||||
|
|
||||||
|
// Step 2: PUBREL from client → PUBCOMP
|
||||||
|
// PUBREL: [0x62] [0x02] [PI high] [PI low]
|
||||||
|
var pubrelBuf = new byte[] { 0x62, 0x02, 0x00, 0x07 };
|
||||||
|
err = MqttParser.Parse(c, pubrelBuf, pubrelBuf.Length);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
|
||||||
|
// Verify PUBCOMP was sent.
|
||||||
|
var written = ms.ToArray();
|
||||||
|
written.Length.ShouldBe(4);
|
||||||
|
written[0].ShouldBe(MqttPacket.PubComp); // 0x70
|
||||||
|
written[1].ShouldBe((byte)0x02);
|
||||||
|
written[2].ShouldBe((byte)0x00);
|
||||||
|
written[3].ShouldBe((byte)0x07);
|
||||||
|
|
||||||
|
// Message should be removed from QoS2Pending.
|
||||||
|
c.Mqtt.QoS2Pending.ShouldNotContainKey((ushort)7);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_PubAck_ShouldRemoveFromPending()
|
||||||
|
{
|
||||||
|
var c = CreateConnectedMqttClient();
|
||||||
|
|
||||||
|
// Pre-populate pending.
|
||||||
|
c.Mqtt!.Pending[(ushort)3] = null;
|
||||||
|
c.Mqtt.Pending.ShouldContainKey((ushort)3);
|
||||||
|
|
||||||
|
// PUBACK: [0x40] [0x02] [0x00] [0x03]
|
||||||
|
var buf = new byte[] { 0x40, 0x02, 0x00, 0x03 };
|
||||||
|
var err = MqttParser.Parse(c, buf, buf.Length);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
|
||||||
|
c.Mqtt.Pending.ShouldNotContainKey((ushort)3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_PubRec_ShouldSendPubRel()
|
||||||
|
{
|
||||||
|
var c = CreateConnectedMqttClient();
|
||||||
|
|
||||||
|
// Pre-populate pending.
|
||||||
|
c.Mqtt!.Pending[(ushort)9] = null;
|
||||||
|
|
||||||
|
// PUBREC: [0x50] [0x02] [0x00] [0x09]
|
||||||
|
var buf = new byte[] { 0x50, 0x02, 0x00, 0x09 };
|
||||||
|
var err = MqttParser.Parse(c, buf, buf.Length);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
|
||||||
|
// Should have sent PUBREL: [0x62] [0x02] [0x00] [0x09]
|
||||||
|
var ms = GetStream(c);
|
||||||
|
var written = ms.ToArray();
|
||||||
|
written.Length.ShouldBe(4);
|
||||||
|
written[0].ShouldBe((byte)0x62); // PUBREL with bit 1 set
|
||||||
|
written[3].ShouldBe((byte)0x09);
|
||||||
|
|
||||||
|
// Pending should be cleared.
|
||||||
|
c.Mqtt.Pending.ShouldNotContainKey((ushort)9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parser_PubComp_ShouldRemoveFromPending()
|
||||||
|
{
|
||||||
|
var c = CreateConnectedMqttClient();
|
||||||
|
|
||||||
|
c.Mqtt!.Pending[(ushort)15] = null;
|
||||||
|
|
||||||
|
// PUBCOMP: [0x70] [0x02] [0x00] [0x0F]
|
||||||
|
var buf = new byte[] { 0x70, 0x02, 0x00, 0x0F };
|
||||||
|
var err = MqttParser.Parse(c, buf, buf.Length);
|
||||||
|
err.ShouldBeNull();
|
||||||
|
|
||||||
|
c.Mqtt.Pending.ShouldNotContainKey((ushort)15);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# NATS .NET Porting Status Report
|
# NATS .NET Porting Status Report
|
||||||
|
|
||||||
Generated: 2026-03-01 21:04:38 UTC
|
Generated: 2026-03-01 21:08:29 UTC
|
||||||
|
|
||||||
## Modules (12 total)
|
## Modules (12 total)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user