perf(modbus): pooled request ADUs, a reused MBAP header and single-buffer RTU deframing

R07:L5 (Modbus half, former F2): ModbusTcpTransport builds the MBAP ADU in a
pooled buffer and reads the reply header into one per-transport buffer (sends are
single-flight under the gate). ModbusRtuOverTcpTransport writes its ADU into a
pooled buffer (ModbusRtuFraming.WriteAdu, byte-identical to BuildAdu), and
ReadResponsePduAsync reads and validates the whole frame in one pooled buffer
instead of five arrays. Only the returned PDU is allocated. The exchange stays one
async method: splitting it cost a state-machine box that ate the saving. Scratch
A/B per op: RTU deframe 352 -> 184 B, RTU send 640 -> 408 B, TCP send 864 -> 800 B;
timings within noise; Driver.Modbus.Tests 2 s before and after. NormalizeWordOrder
lives in ModbusDriver (not a transport) and is left alone.

Refs #747
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:28:53 -04:00
parent 004c09fa13
commit 7e958cd750
4 changed files with 163 additions and 57 deletions
@@ -0,0 +1,62 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// R07:L5 (Modbus half): the RTU ADU is now written into a pooled buffer and the response is deframed in one pooled
/// buffer. The wire bytes and the decoded PDUs must be exactly what the allocating code produced, and a reused pooled
/// buffer must never leak a previous frame's bytes into the next result.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusPooledFramingTests
{
/// <summary>The pooled ADU writer matches the reference construction (unit + PDU, CRC appended low byte first).</summary>
/// <param name="hex">The PDU as hex.</param>
[Theory]
[InlineData("0300000001")]
[InlineData("10000A0002040001000200")]
[InlineData("06")]
public void WriteAdu_matches_the_reference_ADU(string hex)
{
var pdu = Convert.FromHexString(hex);
var reference = ModbusCrc.Append([0x11, .. pdu]);
var pooled = new byte[ModbusRtuFraming.AduLength(pdu.Length) + 7]; // oversized, like a rented array
ModbusRtuFraming.WriteAdu(0x11, pdu, pooled.AsSpan(0, ModbusRtuFraming.AduLength(pdu.Length)));
pooled.AsSpan(0, reference.Length).ToArray().ShouldBe(reference);
ModbusRtuFraming.BuildAdu(0x11, pdu).ShouldBe(reference);
}
/// <summary>
/// A long read reply followed by a short echo and an exception frame, each deframed through the pooled buffer:
/// every result is exactly its own PDU (no stale tail from the previous, longer frame).
/// </summary>
[Fact]
public async Task Consecutive_deframes_through_the_pooled_buffer_do_not_leak_bytes()
{
var ct = TestContext.Current.CancellationToken;
byte[] longRead = [0x03, 200, .. Enumerable.Range(0, 200).Select(i => (byte)(i + 1))];
byte[] echo = [0x06, 0x00, 0x01, 0x00, 0x2A];
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, longRead)), 1, ct))
.ShouldBe(longRead);
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, echo)), 1, ct))
.ShouldBe(echo);
var ex = await Should.ThrowAsync<ModbusException>(() =>
ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, [0x83, 0x02])), 1, ct));
ex.ExceptionCode.ShouldBe((byte)0x02);
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, [0x03, 2, 9, 8])), 1, ct))
.ShouldBe(new byte[] { 0x03, 2, 9, 8 });
}
/// <summary>A byte-count-255 read (the largest RTU reply) fits the pooled frame.</summary>
[Fact]
public async Task The_largest_read_reply_deframes()
{
byte[] max = [0x04, 255, .. new byte[255]];
(await ModbusRtuFraming.ReadResponsePduAsync(new MemoryStream(ModbusRtuFraming.BuildAdu(1, max)), 1,
TestContext.Current.CancellationToken)).ShouldBe(max);
}
}