84e293d2ff
Hosted run 2243 (dc0a24f2, job 3882) failed in
ModbusWritePduFramingTests.FC16_pdu_is_exactly_header_plus_byte_count with
"Sequence contains no matching element" at transport.Sent.Single(fc == 0x10),
one line after the write had already returned status Good: the driver sent the
FC16 PDU, but the record lost it.
The fixture left Probe at its default (Enabled = true), so InitializeAsync
started ProbeLoopAsync on a pool thread, which sends an FC03 at once. Both that
send and the write's send did List<byte[]>.Add on the capturing transport
concurrently; List is not thread-safe and one Add can overwrite the other.
- Disable the probe in this fixture, as the sibling capturing-transport
fixtures (ModbusProtocolOptionsTests, ModbusCoalescingDefaultsTests) do.
- Record PDUs in a ConcurrentQueue so a background send can never drop one.
- Dispose the driver (await using) so no loop outlives the test.
Test-only; not related to the #729/#727 changes (2243 predates them, and the
run that includes them, 2260, passed Driver.Modbus.Tests 1055/1055).
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
116 lines
5.0 KiB
C#
116 lines
5.0 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
|
|
|
/// <summary>
|
|
/// #741 (Integration-W3 fix round): every multi-write PDU the driver builds is EXACTLY its header plus the bytes its
|
|
/// byte-count field declares — FC15 and FC16 are <c>6 + byteCount</c>, FC23 is <c>10 + writeByteCount</c>. The
|
|
/// builders used to allocate one byte more than they filled, so every FC15 / FC16 / FC23 (and the BitInRegister[]
|
|
/// read-modify-write's FC16) carried a trailing zero byte past the declared data — which a strict device or gateway
|
|
/// that checks the frame length against the byte count refuses.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ModbusWritePduFramingTests
|
|
{
|
|
private const string Ref = "arr";
|
|
|
|
/// <summary>
|
|
/// Records every PDU; answers reads with zeros and multi-writes with the standard echo. The record is a
|
|
/// concurrent queue: a plain <c>List</c> lost the FC16 PDU on hosted CI (run 2243, "Sequence contains no
|
|
/// matching element") when the default-on probe loop's FC03 <c>Add</c> raced the write's <c>Add</c>.
|
|
/// </summary>
|
|
private sealed class CapturingTransport : IModbusTransport
|
|
{
|
|
public readonly ConcurrentQueue<byte[]> Sent = new();
|
|
|
|
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
|
{
|
|
Sent.Enqueue(pdu);
|
|
var qty = (pdu[3] << 8) | pdu[4];
|
|
return Task.FromResult(pdu[0] switch
|
|
{
|
|
0x01 or 0x02 => [pdu[0], (byte)((qty + 7) / 8), .. new byte[(qty + 7) / 8]],
|
|
0x03 or 0x04 or 0x17 => [pdu[0], (byte)(qty * 2), .. new byte[qty * 2]],
|
|
0x0F or 0x10 => new[] { pdu[0], pdu[1], pdu[2], pdu[3], pdu[4] },
|
|
_ => pdu,
|
|
});
|
|
}
|
|
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
private static async Task<byte[]> WriteAndCaptureAsync(string tagConfig, object value, byte functionCode, bool fc23 = false)
|
|
{
|
|
var transport = new CapturingTransport();
|
|
// The probe is off: these tests assert on the write's own PDU, and a background probe loop sending FC03
|
|
// concurrently from a pool thread is noise at best (and was a lost-PDU race against the old List record).
|
|
await using var drv = new ModbusDriver(
|
|
new ModbusDriverOptions
|
|
{
|
|
Host = "fake", RawTags = [new RawTagEntry(Ref, tagConfig, false)], UseFC23ForMultiRegisterWrites = fc23,
|
|
Probe = new ModbusProbeOptions { Enabled = false },
|
|
},
|
|
"modbus-framing", _ => transport);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
(await drv.WriteAsync([new WriteRequest(Ref, value)], CancellationToken.None)).Single().StatusCode.ShouldBe(0u);
|
|
|
|
return transport.Sent.Single(p => p[0] == functionCode);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(8)]
|
|
[InlineData(9)]
|
|
public async Task FC15_pdu_is_exactly_header_plus_byte_count(int coils)
|
|
{
|
|
var pdu = await WriteAndCaptureAsync(
|
|
$$"""{"region":"Coils","address":0,"isArray":true,"arrayLength":{{coils}}}""",
|
|
Enumerable.Repeat(true, coils).ToArray(), 0x0F);
|
|
|
|
pdu[5].ShouldBe((byte)((coils + 7) / 8));
|
|
pdu.Length.ShouldBe(6 + pdu[5]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""", 6)]
|
|
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Float64","isArray":true,"arrayLength":2}""", 16)]
|
|
[InlineData("""{"region":"HoldingRegisters","address":0,"dataType":"Int32"}""", 4)] // scalar multi-register
|
|
public async Task FC16_pdu_is_exactly_header_plus_byte_count(string tagConfig, int byteCount)
|
|
{
|
|
object value = tagConfig.Contains("isArray") ? new[] { 1, 2, 3 }.Take(tagConfig.Contains("Float64") ? 2 : 3).ToArray() : 7;
|
|
|
|
var pdu = await WriteAndCaptureAsync(tagConfig, value, 0x10);
|
|
|
|
pdu[5].ShouldBe((byte)byteCount);
|
|
pdu.Length.ShouldBe(6 + pdu[5]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BitInRegister_array_RMW_FC16_pdu_is_exactly_header_plus_byte_count()
|
|
{
|
|
var pdu = await WriteAndCaptureAsync(
|
|
"""{"region":"HoldingRegisters","address":0,"dataType":"BitInRegister","bitIndex":2,"isArray":true,"arrayLength":4}""",
|
|
new[] { true, false, true, false }, 0x10);
|
|
|
|
pdu[5].ShouldBe((byte)8);
|
|
pdu.Length.ShouldBe(6 + pdu[5]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FC23_pdu_is_exactly_header_plus_write_byte_count()
|
|
{
|
|
var pdu = await WriteAndCaptureAsync(
|
|
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","isArray":true,"arrayLength":3}""",
|
|
new[] { 1, 2, 3 }, 0x17, fc23: true);
|
|
|
|
pdu[9].ShouldBe((byte)6);
|
|
pdu.Length.ShouldBe(10 + pdu[9]);
|
|
}
|
|
}
|