mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="BcdPduPipeline"/> using synthetic PDU byte arrays.
|
||||
/// No network, no simulator. Each test builds a hand-rolled <see cref="BcdTagMap"/>,
|
||||
/// calls <see cref="BcdPduPipeline.Process"/>, and asserts resulting bytes + counter deltas.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BcdPduPipelineTests
|
||||
{
|
||||
private static readonly BcdPduPipeline Pipeline = new();
|
||||
|
||||
// ── Factories ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="PerPlcContext"/> from a set of BcdTag entries.
|
||||
/// The context has a fresh <see cref="ProxyCounters"/> instance.
|
||||
/// </summary>
|
||||
private static PerPlcContext MakeContext(params BcdTag[] tags)
|
||||
{
|
||||
var frozen = tags
|
||||
.ToDictionary(t => t.Address)
|
||||
.ToFrozenDictionary();
|
||||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
|
||||
return new PerPlcContext
|
||||
{
|
||||
PlcName = "TestPLC",
|
||||
TagMap = map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 9: the rewriter consumes <see cref="PerPlcContext.CurrentRequest"/> rather
|
||||
/// than a per-pair last-request slot. Tests build a synthetic <see cref="InFlightRequest"/>
|
||||
/// to drive response decoding.
|
||||
/// </summary>
|
||||
private static InFlightRequest MakeInFlight(byte fc, ushort startAddress, ushort qty)
|
||||
=> new(
|
||||
UnitId: 1,
|
||||
Fc: fc,
|
||||
StartAddress: startAddress,
|
||||
Qty: qty,
|
||||
// Phase 9: always exactly one party. We don't have a real UpstreamPipe in
|
||||
// pipeline unit tests; the rewriter never dereferences the party list, so a
|
||||
// null-forgiving placeholder is safe.
|
||||
InterestedParties: Array.Empty<InterestedParty>(),
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>FC03 response PDU: [fc=03][byteCount][reg0Hi][reg0Lo]...</summary>
|
||||
private static byte[] Fc03Response(params ushort[] registers)
|
||||
{
|
||||
var pdu = new byte[2 + registers.Length * 2];
|
||||
pdu[0] = 0x03;
|
||||
pdu[1] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
pdu[2 + i * 2] = (byte)(registers[i] >> 8);
|
||||
pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>FC04 response PDU: same shape as FC03 but fc=04.</summary>
|
||||
private static byte[] Fc04Response(params ushort[] registers)
|
||||
{
|
||||
var pdu = Fc03Response(registers);
|
||||
pdu[0] = 0x04;
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>FC03 request PDU: [fc=03][addrHi][addrLo][qtyHi][qtyLo]</summary>
|
||||
private static byte[] Fc03Request(ushort address, ushort qty)
|
||||
=> [0x03, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(qty >> 8), (byte)(qty & 0xFF)];
|
||||
|
||||
/// <summary>FC06 request PDU: [fc=06][addrHi][addrLo][valHi][valLo]</summary>
|
||||
private static byte[] Fc06Request(ushort address, ushort value)
|
||||
=> [0x06, (byte)(address >> 8), (byte)(address & 0xFF), (byte)(value >> 8), (byte)(value & 0xFF)];
|
||||
|
||||
/// <summary>FC16 request PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount][reg data...]</summary>
|
||||
private static byte[] Fc16Request(ushort start, params ushort[] registers)
|
||||
{
|
||||
ushort qty = (ushort)registers.Length;
|
||||
var pdu = new byte[6 + registers.Length * 2];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(start >> 8);
|
||||
pdu[2] = (byte)(start & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8);
|
||||
pdu[4] = (byte)(qty & 0xFF);
|
||||
pdu[5] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
pdu[6 + i * 2] = (byte)(registers[i] >> 8);
|
||||
pdu[6 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulate sending an FC03/04 request then reading the response.
|
||||
/// Phase 9: builds an <see cref="InFlightRequest"/> matching the request and attaches
|
||||
/// it to the response-call context (replacing the per-pair last-request slot).
|
||||
/// </summary>
|
||||
private void SendRequestThenProcessResponse(
|
||||
PerPlcContext ctx,
|
||||
byte[] requestPdu,
|
||||
byte[] responsePdu)
|
||||
{
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, requestPdu.AsSpan(), ctx);
|
||||
|
||||
// Extract the request start/qty so we can build the InFlightRequest the multiplexer
|
||||
// would attach to the response call.
|
||||
byte fc = requestPdu[0];
|
||||
ushort start = 0, qty = 0;
|
||||
if (fc is 0x03 or 0x04 && requestPdu.Length >= 5)
|
||||
{
|
||||
start = (ushort)((requestPdu[1] << 8) | requestPdu[2]);
|
||||
qty = (ushort)((requestPdu[3] << 8) | requestPdu[4]);
|
||||
}
|
||||
|
||||
var responseCtx = ctx.WithCurrentRequest(MakeInFlight(fc, start, qty));
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, responsePdu.AsSpan(), responseCtx);
|
||||
}
|
||||
|
||||
// ── Helper to read a register pair from a response PDU ──────────────────
|
||||
|
||||
private static ushort ReadReg(byte[] pdu, int offsetWords)
|
||||
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
|
||||
|
||||
// ── FC03 response tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC03_Single16BitBcd_AtReadAddress_DecodesNibbles()
|
||||
{
|
||||
// Raw wire value 0x1234 → decoded binary 1234
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x1234);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)1234);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Full32BitBcdPair_WithinReadRange_DecodesNibbles()
|
||||
{
|
||||
// 32-bit BCD pair at 100/101: low=0x5678 (5678), high=0x1234 (1234)
|
||||
// Decoded = 1234 * 10000 + 5678 = 12345678
|
||||
// Binary: low 4 digits = 5678, high 4 digits = 1234
|
||||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||||
var req = Fc03Request(100, 2);
|
||||
var rsp = Fc03Response(0x5678, 0x1234); // [0]=low, [1]=high
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)5678); // decoded low 4 digits
|
||||
ReadReg(rsp, 1).ShouldBe((ushort)1234); // decoded high 4 digits
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Partial32Bit_LowOnly_qty1_AtLowAddr_PassesThroughRaw()
|
||||
{
|
||||
// Read qty=1 at the low address of a 32-bit pair — only half the pair is in range.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x5678);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0x5678); // unchanged
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Partial32Bit_HighOnly_qty1_AtHighAddr_PassesThroughRaw()
|
||||
{
|
||||
// Read qty=1 starting at the HIGH register of a 32-bit pair (address 101 when tag is at 100).
|
||||
// TryGetForRange returns OffsetWords = -1 for the hit (low register is before the range).
|
||||
var ctx = MakeContext(BcdTag.Create(100, 32));
|
||||
var req = Fc03Request(101, 1); // only reading high register
|
||||
var rsp = Fc03Response(0x1234);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0x1234); // unchanged (partial overlap)
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_Mixed_16BitBcd_And_NonBcd_InSameRead_OnlyBcdSlotRewritten()
|
||||
{
|
||||
// Registers: [0]=non-BCD at addr 99, [1]=BCD 16-bit at addr 100, [2]=non-BCD at addr 101
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var req = Fc03Request(99, 3);
|
||||
var rsp = Fc03Response(0xABCD, 0x1234, 0xDEAD);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0xABCD); // non-BCD, unchanged
|
||||
ReadReg(rsp, 1).ShouldBe((ushort)1234); // BCD decoded
|
||||
ReadReg(rsp, 2).ShouldBe((ushort)0xDEAD); // non-BCD, unchanged
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC03_BadNibble_At16BitBcdSlot_PassesThroughRaw_AndIncrementsInvalidBcd()
|
||||
{
|
||||
// 0x12A4 has nibble 'A' which is not a valid BCD digit.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x12A4);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)0x12A4); // unchanged
|
||||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── FC04 response tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC04_Single16BitBcd_AtReadAddress_DecodesNibbles()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(200, 16));
|
||||
// FC04 request: same shape as FC03 but fc=04
|
||||
var req = new byte[] { 0x04, 0x00, 0xC8, 0x00, 0x01 }; // addr=200, qty=1
|
||||
var rsp = Fc04Response(0x9876);
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)9876);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── FC06 request tests ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC06_Write16BitBcd_EncodesClientBinaryToNibbles()
|
||||
{
|
||||
// Client writes binary 1234 → PLC should receive BCD 0x1234
|
||||
var ctx = MakeContext(BcdTag.Create(300, 16));
|
||||
var pdu = Fc06Request(300, 1234); // client sends binary 1234
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)0x1234); // BCD nibbles
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC06_WriteToLowAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
|
||||
{
|
||||
// FC06 can only write 1 register; if the target is the LOW addr of a 32-bit pair,
|
||||
// that's a partial write — pass through raw.
|
||||
var ctx = MakeContext(BcdTag.Create(400, 32));
|
||||
var pdu = Fc06Request(400, 9999); // 400 is the low address of the 32-bit pair
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)9999); // unchanged (raw binary)
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC06_WriteToHighAddrOf32BitPair_PassesThroughRaw_WithPartialWarning()
|
||||
{
|
||||
// Writing to address 401 when the 32-bit pair is at 400/401 — high register only.
|
||||
var ctx = MakeContext(BcdTag.Create(400, 32));
|
||||
var pdu = Fc06Request(401, 0x1234); // 401 is the high address
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)0x1234); // unchanged
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC06_WriteValueOutsideRange_InvalidBcd_PassesThroughRaw()
|
||||
{
|
||||
// Binary 10000 cannot be represented as 4-digit BCD (max 9999).
|
||||
var ctx = MakeContext(BcdTag.Create(300, 16));
|
||||
var pdu = Fc06Request(300, 10000); // 10000 > 9999
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
ushort sentValue = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
sentValue.ShouldBe((ushort)10000); // raw passthrough
|
||||
ctx.Counters.Snapshot().InvalidBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── FC16 request tests ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC16_WriteSingle16BitBcd_InMultiWrite_EncodesBcdSlotOnly()
|
||||
{
|
||||
// Registers 500, 501, 502, 503: only 502 is a BCD tag.
|
||||
// Non-BCD registers should pass through unchanged.
|
||||
var ctx = MakeContext(BcdTag.Create(502, 16));
|
||||
var pdu = Fc16Request(500, 0x0010, 0x0020, 1234, 0x0040);
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
// Register at offset 0 (addr 500): unchanged
|
||||
ushort r0 = (ushort)((pdu[6] << 8) | pdu[7]);
|
||||
r0.ShouldBe((ushort)0x0010);
|
||||
|
||||
// Register at offset 2 (addr 502): binary 1234 → BCD 0x1234
|
||||
ushort r2 = (ushort)((pdu[10] << 8) | pdu[11]);
|
||||
r2.ShouldBe((ushort)0x1234);
|
||||
|
||||
// Register at offset 3 (addr 503): unchanged
|
||||
ushort r3 = (ushort)((pdu[12] << 8) | pdu[13]);
|
||||
r3.ShouldBe((ushort)0x0040);
|
||||
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC16_WriteFull32BitBcdPair_EncodesAsNibbles()
|
||||
{
|
||||
// 32-bit BCD pair at 600/601: client sends 12345678 as CDAB binary.
|
||||
// The proxy should encode to low=0x5678, high=0x1234.
|
||||
// Client sends: low-4-digits=5678, high-4-digits=1234 (in CDAB order)
|
||||
var ctx = MakeContext(BcdTag.Create(600, 32));
|
||||
// Client sends binary: low register = low 4 digits = 5678, high register = high 4 digits = 1234
|
||||
// But actually the pipeline needs to reconstruct the value:
|
||||
// decoded = clientHigh * 10000 + clientLow = 1234 * 10000 + 5678 = 12345678
|
||||
// Then encode: (bcdLow=0x5678, bcdHigh=0x1234)
|
||||
var pdu = Fc16Request(600, 5678, 1234); // [0]=low-word=5678, [1]=high-word=1234
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
// After encoding: low=BCD(5678)=0x5678, high=BCD(1234)=0x1234
|
||||
ushort sentLow = (ushort)((pdu[6] << 8) | pdu[7]);
|
||||
ushort sentHigh = (ushort)((pdu[8] << 8) | pdu[9]);
|
||||
sentLow.ShouldBe((ushort)0x5678);
|
||||
sentHigh.ShouldBe((ushort)0x1234);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC16_WritePartiallyOverlapping32BitPair_PassesThroughRaw_WithPartialWarning()
|
||||
{
|
||||
// Write range 700–701 (2 regs), but 32-bit BCD tag is at 701/702.
|
||||
// Only the low register (701) is in range; high register (702) is not.
|
||||
var ctx = MakeContext(BcdTag.Create(701, 32));
|
||||
var pdu = Fc16Request(700, 0xAAAA, 0xBBBB); // writes 700 and 701; tag needs 701 and 702
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
// The low register (at offset 1 in pdu, i.e., addr 701) should be unchanged.
|
||||
ushort r1 = (ushort)((pdu[8] << 8) | pdu[9]);
|
||||
r1.ShouldBe((ushort)0xBBBB);
|
||||
ctx.Counters.Snapshot().PartialBcdWarnings.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Pass-through FCs ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC01_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x01, 0x00, 0x64, 0x00, 0x08 }; // FC01 read coils
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC02_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x02, 0x00, 0x64, 0x00, 0x08 }; // FC02 read discrete inputs
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC05_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x05, 0x00, 0x64, 0xFF, 0x00 }; // FC05 write single coil
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FC15_Request_IsPassedThroughUnchanged()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var pdu = new byte[] { 0x0F, 0x00, 0x64, 0x00, 0x08, 0x01, 0xAB }; // FC15 write multiple coils
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Exception response test ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FC03_ExceptionResponse_PassesThroughRaw_LogsPassthrough_IncrementsBackendException()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
// Exception response: [fc|0x80=0x83][exceptionCode=02]
|
||||
var pdu = new byte[] { 0x83, 0x02 };
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original); // bytes unchanged
|
||||
ctx.Counters.Snapshot().BackendException02.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Empty BcdTagMap tests ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EmptyTagMap_FC03Response_ProducesZeroRewrites()
|
||||
{
|
||||
var ctx = MakeContext(/* no tags */);
|
||||
var req = Fc03Request(100, 3);
|
||||
var rsp = Fc03Response(0x1234, 0x5678, 0x9ABC);
|
||||
byte[] original = [..rsp];
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
rsp.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyTagMap_FC06Request_ProducesZeroRewrites()
|
||||
{
|
||||
var ctx = MakeContext(/* no tags */);
|
||||
var pdu = Fc06Request(300, 1234);
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── Counter snapshot accuracy ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CounterSnapshot_ReflectsIncrementsExactly()
|
||||
{
|
||||
// Process 3 FC03 responses with one 16-bit BCD slot each, plus one bad-nibble.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x1234);
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
}
|
||||
|
||||
// One with a bad nibble.
|
||||
{
|
||||
var req = Fc03Request(100, 1);
|
||||
var rsp = Fc03Response(0x12A4);
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
}
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.RewrittenSlots.ShouldBe(3); // 3 successful decodes
|
||||
snap.InvalidBcdWarnings.ShouldBe(1); // 1 bad-nibble pass-through
|
||||
// PdusForwarded = 4 requests + 4 responses = 8
|
||||
snap.PdusForwarded.ShouldBe(8);
|
||||
snap.Fc03.ShouldBe(8); // both request and response increment by FC (request FC03)
|
||||
}
|
||||
|
||||
// ── PDU length invariant ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PduLength_IsNeverChangedByRewriting()
|
||||
{
|
||||
// Build a response with two 16-bit BCD tags. After rewriting, the PDU must be
|
||||
// exactly the same byte count as before.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(101, 16));
|
||||
var req = Fc03Request(100, 2);
|
||||
var rsp = Fc03Response(0x1234, 0x5678);
|
||||
int originalLength = rsp.Length;
|
||||
|
||||
SendRequestThenProcessResponse(ctx, req, rsp);
|
||||
|
||||
rsp.Length.ShouldBe(originalLength); // MBAP transparency contract
|
||||
ctx.Counters.Snapshot().RewrittenSlots.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ── FC counter tracking ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FcCounters_IncrementCorrectly_ForEachFunctionCode()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
|
||||
// FC03 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc03Request(100, 1).AsSpan(), ctx);
|
||||
// FC04 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x04, 0x00, 0x64, 0x00, 0x01 }.AsSpan(), ctx);
|
||||
// FC06 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc06Request(300, 1234).AsSpan(), ctx);
|
||||
// FC16 request
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, Fc16Request(100, 0x1234).AsSpan(), ctx);
|
||||
// FC01 (Other)
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x01, 0x00, 0x00, 0x00, 0x01 }.AsSpan(), ctx);
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.Fc03.ShouldBe(1);
|
||||
snap.Fc04.ShouldBe(1);
|
||||
snap.Fc06.ShouldBe(1);
|
||||
snap.Fc16.ShouldBe(1);
|
||||
snap.FcOther.ShouldBe(1);
|
||||
snap.PdusForwarded.ShouldBe(5);
|
||||
}
|
||||
|
||||
// ── Extra coverage: backend exception codes ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BackendExceptions_AllCodes_TrackSeparately()
|
||||
{
|
||||
var ctx = MakeContext();
|
||||
|
||||
// Codes 1–4 get individual counters; code 5 goes to Other.
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x01 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x02 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x03 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x04 }.AsSpan(), ctx);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty,
|
||||
new byte[] { 0x81, 0x05 }.AsSpan(), ctx); // code 5 → Other
|
||||
|
||||
var snap = ctx.Counters.Snapshot();
|
||||
snap.BackendException01.ShouldBe(1);
|
||||
snap.BackendException02.ShouldBe(1);
|
||||
snap.BackendException03.ShouldBe(1);
|
||||
snap.BackendException04.ShouldBe(1);
|
||||
snap.BackendExceptionOther.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── Plain PduContext (no BCD context) → no-op ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PlainPduContext_IsPassedThroughWithoutError()
|
||||
{
|
||||
// If a plain PduContext is passed (not PerPlcContext), the pipeline must
|
||||
// return cleanly without throwing, leaving bytes unchanged.
|
||||
var ctx = new PduContext { PlcName = "Test" };
|
||||
var pdu = Fc03Response(0x1234);
|
||||
byte[] original = [..pdu];
|
||||
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
|
||||
pdu.ShouldBe(original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using Mbproxy.Proxy;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="MbapFrame"/> header parsing and frame-length helpers.
|
||||
/// All tests are pure in-memory; no network, no simulator required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MbapFrameTests
|
||||
{
|
||||
// ── 1. TryParseHeader — too-short buffers ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryParseHeader_TooShort_ReturnsFalse()
|
||||
{
|
||||
// A buffer of only 6 bytes is one byte short of the 7-byte header.
|
||||
byte[] buf = [0x00, 0x01, 0x00, 0x00, 0x00, 0x06];
|
||||
bool result = MbapFrame.TryParseHeader(buf, out _, out _, out _, out _);
|
||||
Assert.False(result, "Buffer shorter than 7 bytes must return false.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseHeader_EmptyBuffer_ReturnsFalse()
|
||||
{
|
||||
bool result = MbapFrame.TryParseHeader(ReadOnlySpan<byte>.Empty, out _, out _, out _, out _);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// ── 2. TryParseHeader — valid frame parses all fields ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryParseHeader_ValidFrame_ParsesAllFields()
|
||||
{
|
||||
// TxId=0x0042, ProtocolId=0x0000, Length=0x0006, UnitId=0x01
|
||||
byte[] header = [0x00, 0x42, 0x00, 0x00, 0x00, 0x06, 0x01];
|
||||
|
||||
bool ok = MbapFrame.TryParseHeader(header, out ushort txId, out ushort protocolId,
|
||||
out ushort length, out byte unitId);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0x0042, txId);
|
||||
Assert.Equal(0x0000, protocolId);
|
||||
Assert.Equal(6, length);
|
||||
Assert.Equal(1, unitId);
|
||||
}
|
||||
|
||||
// ── 3. Non-zero ProtocolId still parses (PLC's job to reject it) ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryParseHeader_ProtocolId_NotZero_StillParses()
|
||||
{
|
||||
// ProtocolId = 0x0001 (non-standard but we don't filter it).
|
||||
byte[] header = [0x00, 0x01, 0x00, 0x01, 0x00, 0x06, 0xFF];
|
||||
|
||||
bool ok = MbapFrame.TryParseHeader(header, out _, out ushort protocolId, out _, out _);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0x0001, protocolId);
|
||||
}
|
||||
|
||||
// ── 4. TotalFrameLength — known good values ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TotalFrameLength_LengthField7_Returns13()
|
||||
{
|
||||
// 6 fixed prefix bytes + 7 = 13
|
||||
Assert.Equal(13, MbapFrame.TotalFrameLength(7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalFrameLength_LengthFieldMax_Returns_LengthFieldPlus6()
|
||||
{
|
||||
// The formula is always lengthField + 6.
|
||||
ushort max = ushort.MaxValue; // 65535
|
||||
Assert.Equal(max + 6, MbapFrame.TotalFrameLength(max));
|
||||
}
|
||||
|
||||
// ── 5. Round-trip: FC03 read-holding-registers request ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_FC03_ReadHoldingRegisters_Request_ParsesCorrectly()
|
||||
{
|
||||
// FC03 request: TxId=1, ProtocolId=0, Length=6, UnitId=1, FC=0x03, Start=0x0430, Qty=0x0001
|
||||
byte[] frame =
|
||||
[
|
||||
0x00, 0x01, // TxId = 1
|
||||
0x00, 0x00, // ProtocolId = 0
|
||||
0x00, 0x06, // Length = 6
|
||||
0x01, // UnitId = 1
|
||||
0x03, // FC 03
|
||||
0x04, 0x30, // Start address = 0x0430 (decimal 1072)
|
||||
0x00, 0x01, // Quantity = 1
|
||||
];
|
||||
|
||||
bool ok = MbapFrame.TryParseHeader(frame.AsSpan(0, 7),
|
||||
out ushort txId, out ushort protocolId, out ushort length, out byte unitId);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(1, txId);
|
||||
Assert.Equal(0, protocolId);
|
||||
Assert.Equal(6, length);
|
||||
Assert.Equal(1, unitId);
|
||||
|
||||
// Total frame = 6 + length = 12 bytes
|
||||
Assert.Equal(12, MbapFrame.TotalFrameLength(length));
|
||||
Assert.Equal(frame.Length, MbapFrame.TotalFrameLength(length));
|
||||
}
|
||||
|
||||
// ── 6. Round-trip: FC16 write-multiple-registers request ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_FC16_WriteMultipleRegisters_ParsesCorrectly()
|
||||
{
|
||||
// FC16 request: TxId=5, ProtocolId=0, Length=11, UnitId=1
|
||||
// FC=0x10, Start=0x00C8 (200), Qty=2, ByteCount=4, Data=[0x00,0x0A, 0x00,0x14]
|
||||
byte[] frame =
|
||||
[
|
||||
0x00, 0x05, // TxId = 5
|
||||
0x00, 0x00, // ProtocolId = 0
|
||||
0x00, 0x0B, // Length = 11
|
||||
0x01, // UnitId = 1
|
||||
0x10, // FC 16
|
||||
0x00, 0xC8, // Start address = 200
|
||||
0x00, 0x02, // Quantity = 2
|
||||
0x04, // Byte count = 4
|
||||
0x00, 0x0A, // Register 200 = 10
|
||||
0x00, 0x14, // Register 201 = 20
|
||||
];
|
||||
|
||||
bool ok = MbapFrame.TryParseHeader(frame.AsSpan(0, 7),
|
||||
out ushort txId, out _, out ushort length, out byte unitId);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(5, txId);
|
||||
Assert.Equal(11, length);
|
||||
Assert.Equal(1, unitId);
|
||||
|
||||
// Total frame = 6 + 11 = 17
|
||||
Assert.Equal(17, MbapFrame.TotalFrameLength(length));
|
||||
Assert.Equal(frame.Length, MbapFrame.TotalFrameLength(length));
|
||||
}
|
||||
|
||||
// ── 7. Length < 2 — parsed but unusual (callers' responsibility) ───────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryParseHeader_LengthLessThan2_ParsedButUnusual()
|
||||
{
|
||||
// length=1 means only a UnitId byte follows the 6-byte prefix; PDU body = 0 bytes.
|
||||
// The proxy does not reject this — that is the PLC's job. We parse and pass through.
|
||||
byte[] header = [0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01];
|
||||
|
||||
bool ok = MbapFrame.TryParseHeader(header, out _, out _, out ushort length, out _);
|
||||
|
||||
Assert.True(ok, "Header with length=1 should still parse; the proxy does not validate length semantics.");
|
||||
Assert.Equal(1, length);
|
||||
|
||||
// TotalFrameLength still returns 6 + length = 7 (header only, no PDU body).
|
||||
Assert.Equal(7, MbapFrame.TotalFrameLength(length));
|
||||
}
|
||||
|
||||
// ── 8. Exactly 7 bytes — boundary case ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryParseHeader_ExactlySevenBytes_ParsesOk()
|
||||
{
|
||||
byte[] header = [0xFF, 0xFE, 0x00, 0x00, 0x00, 0x06, 0x02];
|
||||
bool ok = MbapFrame.TryParseHeader(header, out ushort txId, out _, out _, out byte unitId);
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0xFFFE, txId);
|
||||
Assert.Equal(2, unitId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CorrelationMap"/>. Pure logic — no I/O.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CorrelationMapTests
|
||||
{
|
||||
private static InFlightRequest MakeReq(byte fc = 0x03, ushort start = 0, ushort qty = 1)
|
||||
=> new(
|
||||
UnitId: 1, Fc: fc, StartAddress: start, Qty: qty,
|
||||
InterestedParties: Array.Empty<InterestedParty>(),
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_Then_TryRemove_RoundTrips()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
var req = MakeReq();
|
||||
|
||||
map.TryAdd(42, req).ShouldBeTrue();
|
||||
map.Count.ShouldBe(1);
|
||||
|
||||
map.TryRemove(42, out var got).ShouldBeTrue();
|
||||
got.ShouldBeSameAs(req);
|
||||
map.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_DuplicateKey_Fails()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
map.TryAdd(7, MakeReq()).ShouldBeTrue();
|
||||
map.TryAdd(7, MakeReq()).ShouldBeFalse("duplicate key must be rejected");
|
||||
map.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRemove_OfMissing_ReturnsFalse()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
map.TryRemove(99, out var got).ShouldBeFalse();
|
||||
got.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReflectsCurrentState()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
var r1 = MakeReq(start: 10);
|
||||
var r2 = MakeReq(start: 20);
|
||||
map.TryAdd(1, r1).ShouldBeTrue();
|
||||
map.TryAdd(2, r2).ShouldBeTrue();
|
||||
|
||||
var snap = map.Snapshot();
|
||||
snap.Count.ShouldBe(2);
|
||||
snap.ShouldContain(r1);
|
||||
snap.ShouldContain(r2);
|
||||
|
||||
map.TryRemove(1, out _).ShouldBeTrue();
|
||||
|
||||
// Snapshot is a copy; doesn't reflect the removal that happened after Snapshot returned.
|
||||
// Re-snapshot to verify state.
|
||||
map.Snapshot().Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_AddRemove_NoDataLoss_Under_Parallel_Stress()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
const int producers = 16;
|
||||
const int opsPerProducer = 4096;
|
||||
|
||||
// Each producer adds a disjoint range and removes it. After all complete, the map
|
||||
// must be empty and no add or remove may have failed for a non-contention reason.
|
||||
await Task.WhenAll(Enumerable.Range(0, producers).Select(p => Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < opsPerProducer; i++)
|
||||
{
|
||||
ushort key = (ushort)((p * opsPerProducer + i) & 0xFFFF);
|
||||
// The 0..65535 range guarantees a few collisions; the test asserts that the
|
||||
// map handles them as documented (TryAdd returns false on duplicate; the
|
||||
// owner removes its own key).
|
||||
if (map.TryAdd(key, MakeReq(start: key)))
|
||||
map.TryRemove(key, out _);
|
||||
}
|
||||
})));
|
||||
|
||||
map.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Mbproxy;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for the Phase-9 TxId multiplexer against the pymodbus DL205 simulator.
|
||||
///
|
||||
/// <para><b>pymodbus 3.13.0 simulator quirk.</b> The simulator's <c>ServerRequestHandler</c>
|
||||
/// stores a single <c>last_pdu</c> field per TCP connection and schedules
|
||||
/// <c>handle_later</c> via <c>asyncio.call_soon</c>. If two MBAP frames arrive in the same
|
||||
/// recv-buffer (which the multiplexer can cause on a shared backend connection), the
|
||||
/// later frame overwrites <c>last_pdu</c> before the first scheduled handler runs,
|
||||
/// and both responses then carry the same TxId. The real DL260 ECOM does not suffer this
|
||||
/// quirk (it properly echoes per-request MBAP TxIds), so this is purely a simulator
|
||||
/// limitation — the multiplexer's TxId rewriting is verified end-to-end against a stub
|
||||
/// backend in <see cref="PlcMultiplexerTests"/>.</para>
|
||||
///
|
||||
/// <para><b>Test strategy here:</b> exercise the connection-cap lift (>4 simultaneous
|
||||
/// upstream clients) and the BCD-rewriter integration against a real PLC-shaped backend,
|
||||
/// but issue requests on each client <i>after</i> the previous client's response has
|
||||
/// returned so the proxy's shared backend conn does not pump concurrent frames into
|
||||
/// pymodbus's broken framer. Mux correctness under truly concurrent backend traffic is
|
||||
/// proven against the stub backend in <see cref="PlcMultiplexerTests"/>.</para>
|
||||
///
|
||||
/// <para>The per-request watchdog (<c>BackendRequestTimeoutMs</c>) in
|
||||
/// <see cref="Mbproxy.Proxy.Multiplexing.PlcMultiplexer"/> defends against pymodbus's bug
|
||||
/// in production by surfacing a Modbus exception 0x0B back to upstream clients after the
|
||||
/// configured timeout — see <see cref="PlcMultiplexerTests"/> for the unit coverage.</para>
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class MultiplexerE2ETests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
public MultiplexerE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;
|
||||
|
||||
// ── E2E 1: Five simultaneous upstream clients (connection-cap lift) ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// Headline test for Phase 9: prove that the multiplexer accepts the 5th upstream
|
||||
/// client on the same proxy port — pre-Phase-9's 1:1 model would have failed at
|
||||
/// backend connect (H2-ECOM100 cap = 4). Each client's request is serialised behind
|
||||
/// the previous client's response so the pymodbus 3.13 simulator's concurrent-frame
|
||||
/// bug never triggers; the multiplexer's connection ceiling, not its under-concurrency
|
||||
/// behaviour, is what this test proves.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_FiveSimultaneousClients_AllReadHR1072_AllGetDecoded_1234()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
};
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
// Open five simultaneous TCP connections to the proxy first (each would have used
|
||||
// a dedicated backend socket pre-Phase-9, blowing through the 4-client cap).
|
||||
var clients = new TcpClient[5];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
clients[i] = new TcpClient();
|
||||
await clients[i].ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// Now issue one read on each client, serialised. The serialisation keeps
|
||||
// pymodbus 3.13's framer in known-good single-PDU mode.
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
var master = new ModbusFactory().CreateMaster(clients[i]);
|
||||
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
regs[0].ShouldBe((ushort)1234, $"client #{i} must see the BCD-decoded value");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── E2E 2: Many sequential requests through 3 clients ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Issue 21 sequential FC03 requests round-robined across three clients. Validates
|
||||
/// per-pipe forwarding, allocator re-use, and counter increments under a sustained
|
||||
/// (if not parallel) load through the multiplexed backend connection.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_TwentyOneSequential_FC03_Requests_AcrossThreeClients_AllSucceed()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
var clients = new TcpClient[3];
|
||||
var masters = new IModbusMaster[3];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
clients[i] = new TcpClient();
|
||||
await clients[i].ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
masters[i] = new ModbusFactory().CreateMaster(clients[i]);
|
||||
}
|
||||
|
||||
// 21 requests round-robin across 3 clients. Serialised so no two requests are
|
||||
// simultaneously in flight on the multiplexer's shared backend connection.
|
||||
int ok = 0;
|
||||
for (int i = 0; i < 21; i++)
|
||||
{
|
||||
_ = masters[i % 3].ReadHoldingRegisters(1, 0, 1);
|
||||
ok++;
|
||||
}
|
||||
ok.ShouldBe(21);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── E2E 3: BCD rewriter still works through the multiplexed model ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Three clients, each writing a different decimal value to a different BCD-configured
|
||||
/// address via FC06 and reading it back. Proves the rewriter and the multiplexer's
|
||||
/// per-request <see cref="Mbproxy.Proxy.Multiplexing.InFlightRequest"/> threading
|
||||
/// preserve BCD encoding round-trips across multiple multiplexed clients.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_RewriterStillWorks_UnderMultiplexedThreeClients()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
// Configure three BCD addresses each width 16 for FC06 writes. The sim profile's
|
||||
// writable HR range is [200..209] (see DL260/dl205.json's "write" list); reads
|
||||
// outside that range succeed but writes return exception 02. We use 200/202/204.
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "200",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
["Mbproxy:BcdTags:Global:1:Address"] = "202",
|
||||
["Mbproxy:BcdTags:Global:1:Width"] = "16",
|
||||
["Mbproxy:BcdTags:Global:2:Address"] = "204",
|
||||
["Mbproxy:BcdTags:Global:2:Width"] = "16",
|
||||
};
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
(ushort addr, ushort val)[] cases =
|
||||
[
|
||||
(200, 1234),
|
||||
(202, 5678),
|
||||
(204, 9999),
|
||||
];
|
||||
|
||||
var clients = new TcpClient[3];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
clients[i] = new TcpClient();
|
||||
await clients[i].ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// Serialised across clients so pymodbus only sees one frame at a time.
|
||||
for (int i = 0; i < cases.Length; i++)
|
||||
{
|
||||
var master = new ModbusFactory().CreateMaster(clients[i]);
|
||||
master.WriteSingleRegister(1, cases[i].addr, cases[i].val);
|
||||
ushort[] regs = master.ReadHoldingRegisters(1, cases[i].addr, 1);
|
||||
regs[0].ShouldBe(cases[i].val,
|
||||
$"BCD round-trip for addr {cases[i].addr} via client #{i} must preserve the client's binary value");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── E2E 4: Status page reflects multiplexer state ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the status JSON surfaces the new Phase-9 mux fields: <c>inFlight</c>,
|
||||
/// <c>maxInFlight</c>, <c>txIdWraps</c>, <c>disconnectCascades</c>, <c>queueDepth</c>.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_StatusPage_Shows_InFlightAndMaxInFlight()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
int adminPort = PickFreePort();
|
||||
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(400, TestContext.Current.CancellationToken);
|
||||
|
||||
// Drive a handful of sequential reads to bump maxInFlight ≥ 1.
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
for (int i = 0; i < 5; i++)
|
||||
_ = master.ReadHoldingRegisters(1, 0, 1);
|
||||
}
|
||||
|
||||
// Now read /status.json and assert the new fields exist and maxInFlight ≥ 1.
|
||||
using var httpClient = new HttpClient();
|
||||
var resp = await httpClient.GetStringAsync(
|
||||
$"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var plc = doc.RootElement.GetProperty("plcs")[0];
|
||||
var backend = plc.GetProperty("backend");
|
||||
|
||||
backend.TryGetProperty("inFlight", out _).ShouldBeTrue("status.json must expose backend.inFlight");
|
||||
backend.TryGetProperty("maxInFlight", out _).ShouldBeTrue("status.json must expose backend.maxInFlight");
|
||||
backend.TryGetProperty("txIdWraps", out _).ShouldBeTrue("status.json must expose backend.txIdWraps");
|
||||
backend.TryGetProperty("disconnectCascades", out _).ShouldBeTrue("status.json must expose backend.disconnectCascades");
|
||||
backend.TryGetProperty("queueDepth", out _).ShouldBeTrue("status.json must expose backend.queueDepth");
|
||||
|
||||
backend.GetProperty("maxInFlight").GetInt64()
|
||||
.ShouldBeGreaterThanOrEqualTo(1, "at least one request must have been in flight during the burst");
|
||||
}
|
||||
|
||||
// ── E2E 5: Backend disconnect cascade + recovery (uses stub backend, not pymodbus) ─
|
||||
|
||||
/// <summary>
|
||||
/// Backend disconnect cascade behaviour. Uses a stand-in stub backend rather than the
|
||||
/// pymodbus simulator so we can kill the backend mid-flight without disturbing the
|
||||
/// shared simulator fixture, AND so we are not subject to pymodbus 3.13's
|
||||
/// concurrent-frame quirk for the multi-client-in-flight scenario.
|
||||
///
|
||||
/// Timeout is 8 s (above the 5 s default) because the test exercises three sequential
|
||||
/// upstream-client connects + a Polly-paced backend reconnect, which intentionally
|
||||
/// includes 50/100/200/500/1000 ms backoffs.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 8_000)]
|
||||
public async Task E2E_BackendDisconnect_DuringInflight_CascadesUpstream_AndRecovers()
|
||||
{
|
||||
// This test uses a stand-in stub backend (not the pymodbus sim) so we can kill
|
||||
// the backend mid-flight without disturbing the shared simulator fixture.
|
||||
int backendPort = PickFreePort();
|
||||
var listener = new TcpListener(IPAddress.Loopback, backendPort);
|
||||
listener.Start();
|
||||
var serverCts = new CancellationTokenSource();
|
||||
var serverToken = serverCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!serverToken.IsCancellationRequested)
|
||||
{
|
||||
var s = await listener.AcceptSocketAsync(serverToken);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Drain forever — never respond. Test will kill us shortly.
|
||||
var buf = new byte[256];
|
||||
while (!serverToken.IsCancellationRequested)
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf, SocketFlags.None, serverToken);
|
||||
if (n == 0) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { try { s.Dispose(); } catch { } }
|
||||
}, serverToken);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}, serverToken);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "Stub",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = "127.0.0.1",
|
||||
[$"Mbproxy:Plcs:0:Port"] = backendPort.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
// Long request timeout so the watchdog doesn't fire during the test's wait window.
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "30000",
|
||||
// Aggressive backend retry so the second connect happens fast.
|
||||
["Mbproxy:Resilience:BackendConnect:MaxAttempts"] = "5",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:0"] = "50",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:1"] = "100",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:2"] = "200",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:3"] = "500",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:4"] = "1000",
|
||||
};
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Connect three clients and start a request from each.
|
||||
var clients = new List<TcpClient>();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var c = new TcpClient();
|
||||
await c.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
await c.GetStream().WriteAsync(BuildRawFc03((ushort)(0x1000 + i), 0, 1), TestContext.Current.CancellationToken);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
// Kill the backend.
|
||||
await serverCts.CancelAsync();
|
||||
listener.Stop();
|
||||
|
||||
// All three should observe a clean EOF.
|
||||
foreach (var c in clients)
|
||||
{
|
||||
var buf = new byte[1];
|
||||
using var d = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
int n;
|
||||
try { n = await c.GetStream().ReadAsync(buf.AsMemory(), d.Token); }
|
||||
catch { n = 0; }
|
||||
n.ShouldBe(0, "upstream must observe a clean EOF after backend cascade");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
|
||||
// Relaunch the stub backend on the same port.
|
||||
var newListener = new TcpListener(IPAddress.Loopback, backendPort);
|
||||
newListener.Start();
|
||||
using var newServerCts = new CancellationTokenSource();
|
||||
var newServerToken = newServerCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = await newListener.AcceptSocketAsync(newServerToken);
|
||||
var buf = new byte[256];
|
||||
while (!newServerToken.IsCancellationRequested)
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf, SocketFlags.None, newServerToken);
|
||||
if (n == 0) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}, newServerToken);
|
||||
|
||||
try
|
||||
{
|
||||
// A new upstream client should successfully connect through the multiplexer
|
||||
// (the multiplexer's backend connect logic will retry through Polly).
|
||||
using var clientD = new TcpClient();
|
||||
await clientD.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
// The write triggers backend reconnect.
|
||||
await clientD.GetStream().WriteAsync(
|
||||
BuildRawFc03(0x2000, 0, 1),
|
||||
TestContext.Current.CancellationToken);
|
||||
// We don't expect a response from our drain-only stub — just verify the
|
||||
// multiplexer didn't drop the upstream socket immediately.
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
clientD.Connected.ShouldBeTrue("upstream socket should remain open after backend reconnect");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await newServerCts.CancelAsync();
|
||||
newListener.Stop();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { serverCts.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private Dictionary<string, string?> MakeBaseConfig(int proxyPort) => new()
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
private static IHost BuildBcdHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddSerilog(
|
||||
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
builder.Services.AddSingleton<ProxyWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
|
||||
if (int.TryParse(config["Mbproxy:AdminPort"], out int admin) && admin > 0)
|
||||
builder.AddMbproxyAdmin();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int p = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return p;
|
||||
}
|
||||
|
||||
private static byte[] BuildRawFc03(ushort txId, ushort start, ushort qty, byte unit = 1)
|
||||
=> [
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unit, 0x03,
|
||||
(byte)(start >> 8), (byte)(start & 0xFF),
|
||||
(byte)(qty >> 8), (byte)(qty & 0xFF),
|
||||
];
|
||||
|
||||
private sealed class AsyncHostDispose : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
public AsyncHostDispose(IHost host) => _host = host;
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
try { await _host.StopAsync(cts.Token); } catch { }
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Frozen;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="PlcMultiplexer"/> against a stub backend
|
||||
/// (a <see cref="TcpListener"/> that canned-responds). Uses real sockets but no simulator.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PlcMultiplexerTests
|
||||
{
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads exactly <paramref name="count"/> bytes from <paramref name="socket"/>.
|
||||
/// </summary>
|
||||
private static async Task<byte[]> ReadExactAsync(Socket socket, int count, CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[count];
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
int n = await socket.ReceiveAsync(buf.AsMemory(read, count - read), SocketFlags.None, ct);
|
||||
if (n == 0) throw new IOException("EOF");
|
||||
read += n;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadOneFrameAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var header = await ReadExactAsync(socket, 7, ct);
|
||||
ushort length = (ushort)((header[4] << 8) | header[5]);
|
||||
int bodyLen = length - 1;
|
||||
var body = bodyLen > 0 ? await ReadExactAsync(socket, bodyLen, ct) : Array.Empty<byte>();
|
||||
var frame = new byte[7 + bodyLen];
|
||||
Buffer.BlockCopy(header, 0, frame, 0, 7);
|
||||
if (bodyLen > 0) Buffer.BlockCopy(body, 0, frame, 7, bodyLen);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] BuildFc03ReadFrame(ushort txId, ushort start, ushort qty, byte unitId = 1)
|
||||
=>
|
||||
[
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unitId,
|
||||
0x03,
|
||||
(byte)(start >> 8), (byte)(start & 0xFF),
|
||||
(byte)(qty >> 8), (byte)(qty & 0xFF),
|
||||
];
|
||||
|
||||
private static byte[] BuildFc06WriteFrame(ushort txId, ushort addr, ushort value, byte unitId = 1)
|
||||
=>
|
||||
[
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unitId,
|
||||
0x06,
|
||||
(byte)(addr >> 8), (byte)(addr & 0xFF),
|
||||
(byte)(value >> 8), (byte)(value & 0xFF),
|
||||
];
|
||||
|
||||
private static byte[] BuildFc03Response(ushort txId, byte unitId, params ushort[] registers)
|
||||
{
|
||||
int bodyLen = 2 + registers.Length * 2; // FC + byteCount + register data
|
||||
var frame = new byte[7 + bodyLen];
|
||||
frame[0] = (byte)(txId >> 8);
|
||||
frame[1] = (byte)(txId & 0xFF);
|
||||
frame[2] = 0;
|
||||
frame[3] = 0;
|
||||
ushort length = (ushort)(1 + bodyLen); // UnitId + PDU
|
||||
frame[4] = (byte)(length >> 8);
|
||||
frame[5] = (byte)(length & 0xFF);
|
||||
frame[6] = unitId;
|
||||
frame[7] = 0x03;
|
||||
frame[8] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
frame[9 + i * 2] = (byte)(registers[i] >> 8);
|
||||
frame[9 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FC06 response echo with txId / addr / value.
|
||||
/// </summary>
|
||||
private static byte[] BuildFc06Response(ushort txId, byte unitId, ushort addr, ushort value)
|
||||
{
|
||||
var frame = new byte[7 + 5];
|
||||
frame[0] = (byte)(txId >> 8);
|
||||
frame[1] = (byte)(txId & 0xFF);
|
||||
frame[2] = 0; frame[3] = 0;
|
||||
frame[4] = 0; frame[5] = 6; // length: UnitId(1) + FC(1) + Addr(2) + Value(2)
|
||||
frame[6] = unitId;
|
||||
frame[7] = 0x06;
|
||||
frame[8] = (byte)(addr >> 8);
|
||||
frame[9] = (byte)(addr & 0xFF);
|
||||
frame[10] = (byte)(value >> 8);
|
||||
frame[11] = (byte)(value & 0xFF);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static PerPlcContext MakeContext(string name, params BcdTag[] tags)
|
||||
{
|
||||
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
|
||||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
return new PerPlcContext
|
||||
{
|
||||
PlcName = name,
|
||||
TagMap = map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stub backend that echoes FC03 responses for every request, recording the proxy
|
||||
/// TxIds it sees on the wire so tests can verify the multiplexer rewrites them.
|
||||
/// </summary>
|
||||
private sealed class StubBackend : IAsyncDisposable
|
||||
{
|
||||
public int Port { get; }
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _clientTasks = new();
|
||||
public ConcurrentQueue<ushort> SeenProxyTxIds { get; } = new();
|
||||
public Func<byte, ushort, ushort, ushort, byte[]>? FcResponseFactory { get; set; }
|
||||
|
||||
public StubBackend(int port)
|
||||
{
|
||||
Port = port;
|
||||
_listener = new TcpListener(IPAddress.Loopback, port);
|
||||
_listener.Start();
|
||||
_ = AcceptLoop();
|
||||
}
|
||||
|
||||
private async Task AcceptLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
Socket s = await _listener.AcceptSocketAsync(_cts.Token);
|
||||
var task = Task.Run(() => HandleAsync(s));
|
||||
lock (_clientTasks) _clientTasks.Add(task);
|
||||
}
|
||||
}
|
||||
catch { /* shutdown */ }
|
||||
}
|
||||
|
||||
private async Task HandleAsync(Socket s)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
var req = await ReadOneFrameAsync(s, _cts.Token);
|
||||
if (req.Length < 8) break;
|
||||
|
||||
ushort txId = (ushort)((req[0] << 8) | req[1]);
|
||||
SeenProxyTxIds.Enqueue(txId);
|
||||
byte unitId = req[6];
|
||||
byte fc = req[7];
|
||||
|
||||
byte[] response;
|
||||
if (FcResponseFactory is not null)
|
||||
{
|
||||
ushort start = req.Length >= 10 ? (ushort)((req[8] << 8) | req[9]) : (ushort)0;
|
||||
ushort qty = req.Length >= 12 ? (ushort)((req[10] << 8) | req[11]) : (ushort)0;
|
||||
response = FcResponseFactory(fc, start, qty, txId);
|
||||
}
|
||||
else if (fc == 0x03)
|
||||
{
|
||||
// Default: FC03 echo a single register containing 0x1234.
|
||||
response = BuildFc03Response(txId, unitId, 0x1234);
|
||||
}
|
||||
else if (fc == 0x06)
|
||||
{
|
||||
ushort addr = (ushort)((req[8] << 8) | req[9]);
|
||||
ushort value = (ushort)((req[10] << 8) | req[11]);
|
||||
response = BuildFc06Response(txId, unitId, addr, value);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
await s.SendAsync(response, SocketFlags.None, _cts.Token);
|
||||
}
|
||||
}
|
||||
catch { /* normal */ }
|
||||
finally { try { s.Dispose(); } catch { } }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
try { _listener.Stop(); } catch { }
|
||||
Task[] snap;
|
||||
lock (_clientTasks) snap = _clientTasks.ToArray();
|
||||
try { await Task.WhenAll(snap).WaitAsync(TimeSpan.FromSeconds(2)); } catch { }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<PlcMultiplexer> BuildMuxAsync(
|
||||
PlcOptions plc, ConnectionOptions connOpts, PerPlcContext ctx)
|
||||
{
|
||||
var mux = new PlcMultiplexer(
|
||||
plc, connOpts,
|
||||
new BcdPduPipeline(),
|
||||
ctx,
|
||||
NullLogger<PlcMultiplexer>.Instance,
|
||||
backendConnectPipeline: null);
|
||||
await Task.Yield();
|
||||
return mux;
|
||||
}
|
||||
|
||||
private static async Task<(Socket client, UpstreamPipe pipe, TcpListener proxyListener, int proxyPort)>
|
||||
ConnectClientAsync(PlcMultiplexer mux, string plcName)
|
||||
{
|
||||
int proxyPort = PickFreePort();
|
||||
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
|
||||
proxyListener.Start();
|
||||
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
|
||||
{ NoDelay = true };
|
||||
await client.ConnectAsync(IPAddress.Loopback, proxyPort);
|
||||
var upstream = await proxyListener.AcceptSocketAsync();
|
||||
var pipe = new UpstreamPipe(upstream, plcName, NullLogger.Instance);
|
||||
_ = Task.Run(() => mux.StartPipeAsync(pipe, CancellationToken.None));
|
||||
|
||||
return (client, pipe, proxyListener, proxyPort);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SingleUpstream_RoundTripsFC03_Through_Multiplexer()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
var ctx = MakeContext("PLC1", BcdTag.Create(100, 16));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await client.SendAsync(BuildFc03ReadFrame(0x1234, 100, 1), SocketFlags.None);
|
||||
var rsp = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
|
||||
|
||||
ushort rspTxId = (ushort)((rsp[0] << 8) | rsp[1]);
|
||||
rspTxId.ShouldBe((ushort)0x1234, "the original TxId must be restored on the way back to the client");
|
||||
|
||||
// BCD decode of the stub's 0x1234 response = 1234.
|
||||
ushort decoded = (ushort)((rsp[9] << 8) | rsp[10]);
|
||||
decoded.ShouldBe((ushort)1234);
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await pipe.DisposeAsync();
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleUpstream_RoundTripsFC06_Through_Multiplexer()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
var ctx = MakeContext("PLC1", BcdTag.Create(200, 16));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Client writes binary 1234; proxy encodes to BCD 0x1234 on the way out.
|
||||
await client.SendAsync(BuildFc06WriteFrame(0xABCD, 200, 1234), SocketFlags.None);
|
||||
var rsp = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
|
||||
|
||||
ushort rspTxId = (ushort)((rsp[0] << 8) | rsp[1]);
|
||||
rspTxId.ShouldBe((ushort)0xABCD);
|
||||
|
||||
// Echo bytes decoded back to client binary.
|
||||
ushort echoed = (ushort)((rsp[10] << 8) | rsp[11]);
|
||||
echoed.ShouldBe((ushort)1234);
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await pipe.DisposeAsync();
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoUpstreams_ConcurrentFC03_BothGetCorrectResponses()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort)
|
||||
{
|
||||
// Both clients read address 100; both should see their own TxId echoed.
|
||||
FcResponseFactory = (fc, start, qty, txId) =>
|
||||
{
|
||||
byte unitId = 1;
|
||||
return fc == 0x03
|
||||
? BuildFc03Response(txId, unitId, 0x1234)
|
||||
: throw new InvalidOperationException("unexpected fc");
|
||||
},
|
||||
};
|
||||
|
||||
var ctx = MakeContext("PLC1", BcdTag.Create(100, 16));
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (c1, p1, l1, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Both clients use the same upstream TxId (0x0001). That would clash on a
|
||||
// shared backend wire if the mux didn't rewrite the TxId.
|
||||
await c1.SendAsync(BuildFc03ReadFrame(0x0001, 100, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03ReadFrame(0x0001, 100, 1), SocketFlags.None);
|
||||
|
||||
var r1 = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
var r2 = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
// Both responses must carry the original (colliding) TxId.
|
||||
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001);
|
||||
((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0001);
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoUpstreams_ProxyTxIds_AreDistinct_OnTheWire()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (c1, p1, l1, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Both clients use the same upstream TxId 0x0007 — the proxy must hand out
|
||||
// distinct proxy TxIds on the backend wire.
|
||||
await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
// Collect what the backend saw.
|
||||
var seen = new HashSet<ushort>(backend.SeenProxyTxIds);
|
||||
seen.Count.ShouldBeGreaterThanOrEqualTo(2, "the multiplexer must allocate distinct proxy TxIds even when upstreams collide");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpstreamDisconnect_DoesNotAffectOtherUpstreams()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new StubBackend(backendPort);
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (cA, pA, lA, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (cB, pB, lB, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Drop client A entirely.
|
||||
cA.Dispose();
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
|
||||
// Client B should still be able to round-trip.
|
||||
await cB.SendAsync(BuildFc03ReadFrame(0x0042, 0, 1), SocketFlags.None);
|
||||
var rsp = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
|
||||
((ushort)((rsp[0] << 8) | rsp[1])).ShouldBe((ushort)0x0042);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cB.Dispose();
|
||||
await pA.DisposeAsync(); await pB.DisposeAsync();
|
||||
lA.Stop(); lB.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackendDisconnect_CascadesToAllUpstreams()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
var backend = new StubBackend(backendPort);
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (cA, pA, lA, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (cB, pB, lB, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (cC, pC, lC, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Force a round-trip on each so backend connect occurs first.
|
||||
await cA.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
|
||||
await cB.SendAsync(BuildFc03ReadFrame(2, 0, 1), SocketFlags.None);
|
||||
await cC.SendAsync(BuildFc03ReadFrame(3, 0, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(cC, TestContext.Current.CancellationToken);
|
||||
|
||||
// Kill the backend.
|
||||
await backend.DisposeAsync();
|
||||
|
||||
// All three upstream sockets should observe a clean EOF within 500 ms.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
await WaitForCloseAsync(cA, TestContext.Current.CancellationToken);
|
||||
await WaitForCloseAsync(cB, TestContext.Current.CancellationToken);
|
||||
await WaitForCloseAsync(cC, TestContext.Current.CancellationToken);
|
||||
sw.Stop();
|
||||
sw.ElapsedMilliseconds.ShouldBeLessThan(2000, "cascade should propagate quickly");
|
||||
|
||||
ctx.Counters.Snapshot().BackendDisconnectCascades.ShouldBeGreaterThanOrEqualTo(3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cA.Dispose(); cB.Dispose(); cC.Dispose();
|
||||
await pA.DisposeAsync(); await pB.DisposeAsync(); await pC.DisposeAsync();
|
||||
lA.Stop(); lB.Stop(); lC.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestTimeoutWatchdog_DeliversException0B_ToUpstream_WhenBackendNeverResponds()
|
||||
{
|
||||
// A drain-only stub that consumes requests but never responds. The multiplexer's
|
||||
// per-request watchdog must surface a Modbus exception 0x0B to the upstream client
|
||||
// once BackendRequestTimeoutMs elapses, freeing the proxy TxId + correlation entry.
|
||||
int backendPort = PickFreePort();
|
||||
var drainListener = new TcpListener(IPAddress.Loopback, backendPort);
|
||||
drainListener.Start();
|
||||
var drainCts = new CancellationTokenSource();
|
||||
var drainToken = drainCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!drainToken.IsCancellationRequested)
|
||||
{
|
||||
var s = await drainListener.AcceptSocketAsync(drainToken);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var buf = new byte[256];
|
||||
try
|
||||
{
|
||||
while (!drainToken.IsCancellationRequested)
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf, SocketFlags.None, drainToken);
|
||||
if (n == 0) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { try { s.Dispose(); } catch { } }
|
||||
}, drainToken);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}, drainToken);
|
||||
|
||||
try
|
||||
{
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
// Short request timeout so the test does not have to wait long.
|
||||
var connOpts = new ConnectionOptions { BackendRequestTimeoutMs = 400 };
|
||||
await using var mux = await BuildMuxAsync(plc, connOpts, ctx);
|
||||
|
||||
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await client.SendAsync(BuildFc03ReadFrame(0xABCD, 0, 1), SocketFlags.None);
|
||||
|
||||
// The watchdog should deliver an exception within ~watchdog-tick * 2.
|
||||
var rsp = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
|
||||
|
||||
ushort rspTxId = (ushort)((rsp[0] << 8) | rsp[1]);
|
||||
rspTxId.ShouldBe((ushort)0xABCD, "watchdog must echo the original client TxId");
|
||||
byte fcByte = rsp[7];
|
||||
(fcByte & 0x80).ShouldBe(0x80, "FC must have the exception bit set");
|
||||
(fcByte & 0x7F).ShouldBe(0x03, "original FC must be FC03 (read holding registers)");
|
||||
rsp[8].ShouldBe((byte)0x0B, "exception code must be 0x0B (Gateway Target Device Failed To Respond)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await pipe.DisposeAsync();
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drainCts.CancelAsync();
|
||||
try { drainListener.Stop(); } catch { }
|
||||
drainCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackendReconnect_AfterCascade_NextUpstreamRequest_Succeeds()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
var backend = new StubBackend(backendPort);
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
|
||||
|
||||
var (cA, pA, lA, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await cA.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
|
||||
_ = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken);
|
||||
|
||||
await backend.DisposeAsync();
|
||||
await WaitForCloseAsync(cA, TestContext.Current.CancellationToken);
|
||||
cA.Dispose();
|
||||
await pA.DisposeAsync();
|
||||
lA.Stop();
|
||||
}
|
||||
catch { /* tolerate any teardown noise */ }
|
||||
|
||||
// Start a new backend on the same port.
|
||||
await using var backend2 = new StubBackend(backendPort);
|
||||
|
||||
// A fresh client should round-trip cleanly through the same multiplexer.
|
||||
var (cB, pB, lB, _) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await cB.SendAsync(BuildFc03ReadFrame(0x7777, 0, 1), SocketFlags.None);
|
||||
var rsp = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
|
||||
((ushort)((rsp[0] << 8) | rsp[1])).ShouldBe((ushort)0x7777);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cB.Dispose();
|
||||
await pB.DisposeAsync();
|
||||
lB.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForCloseAsync(Socket s, CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[1];
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
while (!deadline.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf, SocketFlags.None, deadline.Token);
|
||||
if (n == 0) return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="BcdPduPipeline"/> correlates FC03/FC04 responses through
|
||||
/// <see cref="PerPlcContext.CurrentRequest"/> (Phase 9) rather than the pre-Phase-9
|
||||
/// per-pair last-request slot. Concurrent in-flight requests from different upstream
|
||||
/// clients must decode against their own request range without cross-talk.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RewriterCorrelationTests
|
||||
{
|
||||
private static readonly BcdPduPipeline Pipeline = new();
|
||||
|
||||
private static PerPlcContext MakeContext(params BcdTag[] tags)
|
||||
{
|
||||
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
|
||||
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
|
||||
return new PerPlcContext
|
||||
{
|
||||
PlcName = "MuxTest",
|
||||
TagMap = map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
};
|
||||
}
|
||||
|
||||
private static InFlightRequest MakeReq(byte fc, ushort start, ushort qty)
|
||||
=> new(
|
||||
UnitId: 1, Fc: fc, StartAddress: start, Qty: qty,
|
||||
InterestedParties: Array.Empty<InterestedParty>(),
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
private static byte[] Fc03Response(params ushort[] registers)
|
||||
{
|
||||
var pdu = new byte[2 + registers.Length * 2];
|
||||
pdu[0] = 0x03;
|
||||
pdu[1] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
pdu[2 + i * 2] = (byte)(registers[i] >> 8);
|
||||
pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static ushort ReadReg(byte[] pdu, int offsetWords)
|
||||
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the rewriter reads address+qty from <see cref="PerPlcContext.CurrentRequest"/>
|
||||
/// (not from any per-pair slot) when processing an FC03 response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FC03Response_DecodedViaInFlightRequest_NotPerPairSlot()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
|
||||
// Build a response with raw BCD nibbles at address 100; no prior request was sent
|
||||
// on this context. Without CurrentRequest, the rewriter must NOT touch the bytes.
|
||||
var pdu = Fc03Response(0x1234);
|
||||
byte[] original = [.. pdu];
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
pdu.ShouldBe(original, "without CurrentRequest the rewriter has no correlation; bytes must pass through");
|
||||
|
||||
// Now attach a CurrentRequest that points at address 100 / qty 1.
|
||||
var withReq = ctx.WithCurrentRequest(MakeReq(fc: 0x03, start: 100, qty: 1));
|
||||
pdu = Fc03Response(0x1234);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), withReq);
|
||||
ReadReg(pdu, 0).ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two concurrent in-flight responses with different start addresses must each decode
|
||||
/// against their own request range — proves no shared-mutable-state cross-talk.
|
||||
/// Delivers them out of order to make sure ordering doesn't accidentally mask the bug.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConcurrentFC03_FromTwoUpstreams_DecodeCorrectly_NoCrossTalk()
|
||||
{
|
||||
// Tags at address 100 and 200, both 16-bit.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(200, 16));
|
||||
|
||||
// Request A reads addr 100 / qty 1. Response has BCD nibbles 0x1234 (decimal 1234).
|
||||
var ctxA = ctx.WithCurrentRequest(MakeReq(0x03, 100, 1));
|
||||
var rspA = Fc03Response(0x1234);
|
||||
|
||||
// Request B reads addr 200 / qty 1. Response has BCD nibbles 0x9876 (decimal 9876).
|
||||
var ctxB = ctx.WithCurrentRequest(MakeReq(0x03, 200, 1));
|
||||
var rspB = Fc03Response(0x9876);
|
||||
|
||||
// Deliver B first, then A.
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspB.AsSpan(), ctxB);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspA.AsSpan(), ctxA);
|
||||
|
||||
ReadReg(rspB, 0).ShouldBe((ushort)9876, "B must decode against its own start address (200)");
|
||||
ReadReg(rspA, 0).ShouldBe((ushort)1234, "A must decode against its own start address (100)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FC06 responses are correlated via the address embedded in the echo, not via
|
||||
/// CurrentRequest. This test verifies two concurrent FC06 echoes from different
|
||||
/// upstreams each decode correctly when the rewriter ran their requests first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConcurrentFC06_FromTwoUpstreams_EncodeCorrectly()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(300, 16), BcdTag.Create(400, 16));
|
||||
|
||||
// Client A writes binary 1234 to address 300.
|
||||
var reqA = new byte[] { 0x06, 0x01, 0x2C, 0x04, 0xD2 }; // addr=300, value=1234
|
||||
var ctxA = ctx.WithCurrentRequest(MakeReq(0x06, 300, 1));
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, reqA.AsSpan(), ctxA);
|
||||
((reqA[3] << 8) | reqA[4]).ShouldBe(0x1234, "client A request must be BCD-encoded to 0x1234");
|
||||
|
||||
// Client B writes binary 5678 to address 400.
|
||||
var reqB = new byte[] { 0x06, 0x01, 0x90, 0x16, 0x2E }; // addr=400, value=5678
|
||||
var ctxB = ctx.WithCurrentRequest(MakeReq(0x06, 400, 1));
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, reqB.AsSpan(), ctxB);
|
||||
((reqB[3] << 8) | reqB[4]).ShouldBe(0x5678, "client B request must be BCD-encoded to 0x5678");
|
||||
|
||||
// Now both responses echo the BCD nibbles. The rewriter must decode them.
|
||||
var rspA = new byte[] { 0x06, 0x01, 0x2C, 0x12, 0x34 };
|
||||
var rspB = new byte[] { 0x06, 0x01, 0x90, 0x56, 0x78 };
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspA.AsSpan(), ctxA);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspB.AsSpan(), ctxB);
|
||||
|
||||
((rspA[3] << 8) | rspA[4]).ShouldBe(1234);
|
||||
((rspB[3] << 8) | rspB[4]).ShouldBe(5678);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rewriter must not throw if the response arrives after the upstream has gone
|
||||
/// away. The multiplexer drops responses for dead pipes silently — but the rewriter
|
||||
/// runs on the response regardless, so a dropped party should produce no exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResponseForDeadUpstream_IsDropped_NoExceptionPropagates()
|
||||
{
|
||||
// Dead upstream is modeled by an empty InterestedParties list (the multiplexer
|
||||
// discovered on cascade walk that the pipe was no longer alive).
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var ctxWithReq = ctx.WithCurrentRequest(MakeReq(0x03, 100, 1));
|
||||
|
||||
var rsp = Fc03Response(0x1234);
|
||||
// No assertion needed beyond "does not throw"; the rewriter is purely a bytes
|
||||
// operation and is unaware of upstream liveness.
|
||||
Should.NotThrow(() =>
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rsp.AsSpan(), ctxWithReq));
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)1234, "the bytes were still rewritten in place");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TxIdAllocator"/>. Pure logic — no I/O.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TxIdAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allocate_FromEmpty_Returns_NextSequential()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
alloc.TryAllocate(out ushort a).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort b).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort c).ShouldBeTrue();
|
||||
|
||||
a.ShouldBe((ushort)0);
|
||||
b.ShouldBe((ushort)1);
|
||||
c.ShouldBe((ushort)2);
|
||||
alloc.InFlightCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AfterRelease_Reuses_FreedId()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
alloc.TryAllocate(out ushort a).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort b).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort c).ShouldBeTrue();
|
||||
|
||||
// Release the middle slot and allocate again. The next allocation should advance
|
||||
// forward from the cursor (3) and not re-use 1 until the cursor wraps and finds it free.
|
||||
alloc.Release(b);
|
||||
alloc.InFlightCount.ShouldBe(2);
|
||||
|
||||
alloc.TryAllocate(out ushort d).ShouldBeTrue();
|
||||
d.ShouldBe((ushort)3, "allocator advances the cursor; freed slot 1 reuses only after wrap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AllocatesEveryUshort_BeforeWrapping()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
var seen = new HashSet<ushort>();
|
||||
|
||||
for (int i = 0; i < 65536; i++)
|
||||
{
|
||||
alloc.TryAllocate(out ushort id).ShouldBeTrue($"allocation {i} should succeed");
|
||||
seen.Add(id).ShouldBeTrue($"id {id} should be unique across the full 0..65535 sweep");
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(65536);
|
||||
alloc.InFlightCount.ShouldBe(65536);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_WrapsCorrectly_After0xFFFF()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
// Allocate every slot then release slot 5.
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
|
||||
alloc.Release(5);
|
||||
|
||||
// Next allocation should find slot 5 after the cursor wraps.
|
||||
alloc.TryAllocate(out ushort id).ShouldBeTrue();
|
||||
id.ShouldBe((ushort)5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_WhenSaturated_ReturnsFalse_DoesNotThrow()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
|
||||
alloc.TryAllocate(out ushort id).ShouldBeFalse("saturated allocator must refuse cleanly");
|
||||
id.ShouldBe((ushort)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_OfNonAllocated_IsNoOp()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
alloc.TryAllocate(out ushort a).ShouldBeTrue();
|
||||
// a == 0. Release a slot that was never allocated.
|
||||
alloc.Release(42);
|
||||
alloc.InFlightCount.ShouldBe(1, "releasing a non-allocated id must not decrement the count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_AllocateRelease_NoDuplicateIds_Under_Parallel_Stress()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
const int taskCount = 100;
|
||||
const int opsPerTask = 1000;
|
||||
|
||||
// Each task allocates and immediately releases its id, hammering the lock.
|
||||
// If allocate ever hands out a duplicate, two tasks would see the same id.
|
||||
var observed = new System.Collections.Concurrent.ConcurrentDictionary<int, byte>();
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, taskCount).Select(_ => Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < opsPerTask; i++)
|
||||
{
|
||||
if (!alloc.TryAllocate(out ushort id))
|
||||
continue;
|
||||
// Add a unique tag to detect a duplicate live id.
|
||||
observed.TryAdd(id, 1).ShouldBeTrue();
|
||||
observed.TryRemove(id, out byte _);
|
||||
alloc.Release(id);
|
||||
}
|
||||
})));
|
||||
|
||||
alloc.InFlightCount.ShouldBe(0, "every allocation was released; count must be back to 0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapCount_IncrementsOnEachFullWrap()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
alloc.WrapCount.ShouldBe(0);
|
||||
|
||||
// First sweep: 65536 allocations bring the cursor from 0 back to 0 → one wrap.
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
|
||||
alloc.WrapCount.ShouldBe(1);
|
||||
|
||||
// Release everything, then sweep again: should bump WrapCount to 2.
|
||||
for (ushort i = 0; ; i++)
|
||||
{
|
||||
alloc.Release(i);
|
||||
if (i == 65535) break;
|
||||
}
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
alloc.WrapCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proxy forwarding tests.
|
||||
/// Each test:
|
||||
/// 1. Starts the proxy host in-process, configured with one PLC pointing at the simulator.
|
||||
/// 2. Connects NModbus to the proxy's listen port.
|
||||
/// 3. Asserts the proxy forwards bytes transparently (NoopPduPipeline — no BCD rewriting).
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class ProxyForwardingTests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
|
||||
public ProxyForwardingTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
|
||||
{
|
||||
_sim = sim;
|
||||
}
|
||||
|
||||
// ── 1. FC03 read HR0 — expect 0xCAFE ───────────────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC03_HR0_Returns_SimulatorRawValue_0xCAFE()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 0, numberOfPoints: 1);
|
||||
|
||||
Assert.Equal(0xCAFE, regs[0]);
|
||||
}
|
||||
|
||||
// ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ──────────────────────
|
||||
// Replaced Phase 03 placeholder: Forward_FC03_HR1072_Returns_RawBCD_0x1234
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC03_HR1072_Returns_Decoded_1234()
|
||||
{
|
||||
// Phase 04: BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured,
|
||||
// the proxy decodes the raw 0x1234 nibbles and the client receives binary 1234.
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
// Configure address 1072 as a 16-bit BCD tag.
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var host = BuildBcdProxyHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
|
||||
|
||||
// BCD decoded: 0x1234 → binary 1234.
|
||||
Assert.Equal(1234, regs[0]);
|
||||
}
|
||||
|
||||
// ── 2b. FC03 read HR1072 — without BCD configured → raw 0x1234 ─────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC03_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234()
|
||||
{
|
||||
// When no BCD tag is configured at address 1072, the proxy passes bytes through
|
||||
// unmodified. Client receives raw BCD nibbles 0x1234 (= 4660 decimal).
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
|
||||
|
||||
// No BCD tag configured: raw BCD nibbles pass through.
|
||||
Assert.Equal(0x1234, regs[0]);
|
||||
}
|
||||
|
||||
// ── 3. FC06 write single register then read back ────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC06_WriteHR200_ThenReadBack_RoundTrips()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
const ushort writeValue = 0xABCD;
|
||||
master.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: writeValue);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1);
|
||||
Assert.Equal(writeValue, regs[0]);
|
||||
}
|
||||
|
||||
// ── 4. FC16 write multiple registers then read back ──────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC16_WriteMultipleHR201_203_ThenReadBack_RoundTrips()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] writeValues = [0x0010, 0x0020, 0x0030];
|
||||
master.WriteMultipleRegisters(slaveAddress: 1, startAddress: 201, data: writeValues);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 201, numberOfPoints: 3);
|
||||
Assert.Equal(writeValues, regs);
|
||||
}
|
||||
|
||||
// ── 5. MBAP TxId preserved end-to-end ────────────────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task MbapTxId_IsPreservedEndToEnd()
|
||||
{
|
||||
// Issue 20 back-to-back FC03 reads with manually-incrementing TxIds (via raw sockets)
|
||||
// and verify every response carries the matching TxId.
|
||||
// This verifies no mid-stream frame split causes a parse failure under stress.
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket.NoDelay = true;
|
||||
await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
|
||||
const int count = 20;
|
||||
byte[] reqBuf = new byte[12]; // FC03 request frame
|
||||
byte[] rspBuf = new byte[260];
|
||||
|
||||
for (ushort txId = 1; txId <= count; txId++)
|
||||
{
|
||||
// Build FC03 request: read 1 register at address 0.
|
||||
// [TxId(2), ProtocolId(2)=0, Length(2)=6, UnitId=1, FC=03, Start(2)=0, Qty(2)=1]
|
||||
reqBuf[0] = (byte)(txId >> 8);
|
||||
reqBuf[1] = (byte)(txId & 0xFF);
|
||||
reqBuf[2] = 0x00; // ProtocolId high
|
||||
reqBuf[3] = 0x00; // ProtocolId low
|
||||
reqBuf[4] = 0x00; // Length high
|
||||
reqBuf[5] = 0x06; // Length low (6 bytes: UnitId + FC + 4 PDU bytes)
|
||||
reqBuf[6] = 0x01; // UnitId
|
||||
reqBuf[7] = 0x03; // FC03
|
||||
reqBuf[8] = 0x00; // Start addr high
|
||||
reqBuf[9] = 0x00; // Start addr low
|
||||
reqBuf[10] = 0x00; // Qty high
|
||||
reqBuf[11] = 0x01; // Qty low
|
||||
|
||||
await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
|
||||
// Read response header (7 bytes), then body.
|
||||
int read = 0;
|
||||
while (read < 7)
|
||||
read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
|
||||
// Parse response TxId.
|
||||
ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]);
|
||||
ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]);
|
||||
|
||||
Assert.Equal(txId, rspTxId);
|
||||
|
||||
// Drain the response body.
|
||||
int bodyLen = rspLength - 1; // length covers UnitId + PDU; we already read UnitId
|
||||
if (bodyLen > 0)
|
||||
{
|
||||
int bodyRead = 0;
|
||||
while (bodyRead < bodyLen)
|
||||
bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Backend connect failure — upstream socket closes cleanly ───────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task BackendConnectFailure_ClosesUpstreamCleanly()
|
||||
{
|
||||
// Point the proxy at port 1 on loopback — guaranteed unreachable.
|
||||
// After Phase 9 the multiplexer lazily connects to the backend on the first
|
||||
// upstream PDU, so we have to actually send a request before the proxy attempts
|
||||
// the (failing) backend connect that closes the upstream.
|
||||
const int badBackendPort = 1;
|
||||
const int backendTimeoutMs = 500; // short timeout for test speed
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "BadPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = "127.0.0.1",
|
||||
[$"Mbproxy:Plcs:0:Port"] = badBackendPort.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = backendTimeoutMs.ToString(),
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
var host = BuildProxyHost(config);
|
||||
await host.StartAsync(cts.Token);
|
||||
|
||||
// Give the proxy a moment to bind.
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
|
||||
// Send a Modbus request so the multiplexer attempts the backend connect.
|
||||
byte[] req =
|
||||
[
|
||||
0x00, 0x01, // TxId
|
||||
0x00, 0x00, // ProtocolId
|
||||
0x00, 0x06, // Length
|
||||
0x01, // UnitId
|
||||
0x03, // FC03
|
||||
0x00, 0x00, // Start
|
||||
0x00, 0x01, // Qty
|
||||
];
|
||||
await client.GetStream().WriteAsync(req, TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait up to BackendConnectTimeoutMs + 600ms for the upstream socket to close.
|
||||
// Polly default retry adds extra time, so we account for it in the deadline.
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(backendTimeoutMs + 1500);
|
||||
bool closed = false;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
// A 0-byte receive returns 0 when the remote end closed the socket.
|
||||
var buf = new byte[1];
|
||||
int n = await client.GetStream()
|
||||
.ReadAsync(buf.AsMemory(), TestContext.Current.CancellationToken);
|
||||
if (n == 0) { closed = true; break; }
|
||||
}
|
||||
catch
|
||||
{
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
await host.StopAsync(cts.Token);
|
||||
|
||||
Assert.True(closed, "Upstream socket should have been closed by the proxy after backend connect failure.");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartProxyAsync()
|
||||
{
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var host = BuildProxyHost(config);
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
// Give the proxy time to bind.
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
return (proxyPort, host, runCts);
|
||||
}
|
||||
|
||||
private static IHost BuildProxyHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
// Suppress verbose logging in tests.
|
||||
builder.Services.AddSerilog(
|
||||
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
// Tests in ProxyForwardingTests use NoopPduPipeline to verify raw passthrough
|
||||
// (baseline behaviour independent of BCD configuration).
|
||||
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
||||
builder.Services.AddHostedService<ProxyWorker>();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static IHost BuildBcdProxyHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddSerilog(
|
||||
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
// BCD rewriter pipeline — used by the Phase 04 tests in this file.
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
builder.Services.AddHostedService<ProxyWorker>();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the host and CTS when the test finishes.</summary>
|
||||
private sealed class AsyncHostDispose : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public AsyncHostDispose(IHost host, CancellationTokenSource cts)
|
||||
{
|
||||
_host = host;
|
||||
_cts = cts;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ }
|
||||
_host.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for the BCD rewriter pipeline against the pymodbus DL205 simulator.
|
||||
///
|
||||
/// Each test starts an in-process proxy host configured to point at the simulator,
|
||||
/// connects an NModbus client to the proxy's listen port, and asserts bidirectional
|
||||
/// BCD rewriting behaviour.
|
||||
///
|
||||
/// All tests skip gracefully when the simulator is unavailable (Python / pymodbus missing).
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class RewriterE2ETests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
|
||||
public RewriterE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
|
||||
{
|
||||
_sim = sim;
|
||||
}
|
||||
|
||||
// ── 1. FC03 HR1072 with BCD configured → decoded 1234 ────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Configure a 16-bit BCD tag at address 1072 (seeded 0x1234 in the simulator).
|
||||
/// The proxy should decode the BCD nibbles and return binary 1234 to the client.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Read_HR1072_AsBcd_ReturnsDecoded_1234()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [1072]);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
|
||||
|
||||
// Simulator stores 0x1234 = raw BCD. Proxy should decode → 1234 decimal.
|
||||
regs[0].ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
// ── 2. FC03 HR1072 without BCD configured → raw 0x1234 ───────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Same address, no BCD tags configured. The proxy passes the raw BCD nibbles through.
|
||||
/// Verifies the rewriter is opt-in per tag.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Read_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
// Empty BCD tag list — no rewriting.
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: []);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
|
||||
|
||||
// Raw BCD nibbles pass through unchanged.
|
||||
regs[0].ShouldBe((ushort)0x1234);
|
||||
}
|
||||
|
||||
// ── 3. FC06 write BCD → simulator stores encoded nibbles ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Configure a 16-bit BCD tag at address 200 (in the simulator's writable scratch range).
|
||||
/// Write decimal 9876 through the proxy; read back raw from the simulator and expect 0x9876.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Write_HR200_AsBcd_StoresEncoded_0x9876()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [200]);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
// Write through the proxy (client side: binary 9876).
|
||||
using var proxyClient = new TcpClient();
|
||||
await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var proxyMaster = new ModbusFactory().CreateMaster(proxyClient);
|
||||
proxyMaster.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: 9876);
|
||||
|
||||
// Read raw from the simulator directly (bypassing the proxy).
|
||||
using var simClient = new TcpClient();
|
||||
await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken);
|
||||
var simMaster = new ModbusFactory().CreateMaster(simClient);
|
||||
ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1);
|
||||
|
||||
// Simulator should store BCD-encoded 9876 = 0x9876.
|
||||
raw[0].ShouldBe((ushort)0x9876);
|
||||
}
|
||||
|
||||
// ── 4. FC03 read 32-bit BCD pair at HR1072/HR1073 (CDAB) ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Reads a 32-bit BCD pair at address 1072/1073 (CDAB layout).
|
||||
/// Simulator seeds: 1072=0x1234 (low word), 1073=0x0000 (high word).
|
||||
/// Decoded = 0*10000 + 1234 = 1234.
|
||||
/// This verifies the CDAB word order is handled end-to-end.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Read_HR1072_HR1073_AsBcd32_ReturnsDecoded_From_CDAB()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd32Addresses: [1072]);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
// Read both registers of the 32-bit pair.
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 2);
|
||||
|
||||
// After decoding: low 4 digits = 1234, high 4 digits = 0
|
||||
// The proxy returns decoded binary values in CDAB order:
|
||||
// regs[0] = low 4 decoded digits = 1234
|
||||
// regs[1] = high 4 decoded digits = 0
|
||||
regs[0].ShouldBe((ushort)1234); // decoded low 4 digits
|
||||
regs[1].ShouldBe((ushort)0); // decoded high 4 digits
|
||||
}
|
||||
|
||||
// ── 5. Partial FC03 on high register of 32-bit pair → raw + warning ──────
|
||||
|
||||
/// <summary>
|
||||
/// Read only the high register (1073) of a 32-bit BCD pair at 1072/1073.
|
||||
/// The proxy cannot decode a partial pair — it should pass through raw and log
|
||||
/// mbproxy.rewrite.partial_bcd.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Partial_FC03_OnHighRegisterOf_32BitPair_PassesThroughRaw_AndLogsWarning()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var sink = new CapturingSink();
|
||||
var serilog = new LoggerConfiguration()
|
||||
.MinimumLevel.Warning()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(
|
||||
bcd32Addresses: [1072],
|
||||
serilogOverride: serilog);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
// Read only the high register (1073) — partial overlap for the 32-bit pair.
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1073, numberOfPoints: 1);
|
||||
|
||||
// The raw simulator value for HR1073 is 0x0000 (high word of the 32-bit pair).
|
||||
regs[0].ShouldBe((ushort)0x0000); // raw passthrough
|
||||
|
||||
// The partial_bcd warning should have been logged.
|
||||
var partialEvents = sink.Events
|
||||
.Where(e => e.MessageTemplate.Text.Contains("mbproxy.rewrite.partial_bcd")
|
||||
|| e.MessageTemplate.Text.Contains("Partial BCD overlap"))
|
||||
.ToList();
|
||||
partialEvents.ShouldNotBeEmpty("Expected mbproxy.rewrite.partial_bcd warning to be logged");
|
||||
}
|
||||
|
||||
// ── 6. MBAP TxId preserved after rewriting (20 consecutive) ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// Issues 20 consecutive FC03 reads with manually-incremented TxIds through a proxy
|
||||
/// that has BCD rewriting active (tag at 1072). Verifies the MBAP header is never
|
||||
/// tampered with by the rewriter.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task MbapTxId_StillPreserved_AfterRewriting_20Consecutive()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [1072]);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket.NoDelay = true;
|
||||
await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
|
||||
const int count = 20;
|
||||
byte[] reqBuf = new byte[12]; // FC03 request frame
|
||||
byte[] rspBuf = new byte[260];
|
||||
|
||||
for (ushort txId = 1; txId <= count; txId++)
|
||||
{
|
||||
// Build FC03 request: read 1 register at address 1072.
|
||||
reqBuf[0] = (byte)(txId >> 8);
|
||||
reqBuf[1] = (byte)(txId & 0xFF);
|
||||
reqBuf[2] = 0x00;
|
||||
reqBuf[3] = 0x00;
|
||||
reqBuf[4] = 0x00;
|
||||
reqBuf[5] = 0x06; // Length
|
||||
reqBuf[6] = 0x01; // UnitId
|
||||
reqBuf[7] = 0x03; // FC03
|
||||
reqBuf[8] = 0x04; // Start addr high (1072 = 0x0430)
|
||||
reqBuf[9] = 0x30; // Start addr low
|
||||
reqBuf[10] = 0x00;
|
||||
reqBuf[11] = 0x01; // Qty = 1
|
||||
|
||||
await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
|
||||
// Read 7-byte response header.
|
||||
int read = 0;
|
||||
while (read < 7)
|
||||
read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]);
|
||||
ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]);
|
||||
|
||||
rspTxId.ShouldBe(txId, $"TxId mismatch on iteration {txId}");
|
||||
|
||||
// Drain the body.
|
||||
int bodyLen = rspLength - 1;
|
||||
if (bodyLen > 0)
|
||||
{
|
||||
int bodyRead = 0;
|
||||
while (bodyRead < bodyLen)
|
||||
bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead),
|
||||
SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. FC16 with 16-bit BCD in middle of write range ────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// FC16 (Write Multiple Registers) covering a 3-register span where only the middle
|
||||
/// register is a configured BCD tag. The proxy must encode the middle slot and leave
|
||||
/// the flanks untouched. Verifies per-register selectivity within a multi-register write.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Write_FC16_With_Bcd16_InRange_StoresEncoded_AtOnlyTheBcdSlot()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
// Configure a 16-bit BCD tag at the middle register of a 3-register write.
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd16Addresses: [205]);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
// FC16 write to HR204..HR206 with binary values [10, 9876, 20].
|
||||
using var proxyClient = new TcpClient();
|
||||
await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var proxyMaster = new ModbusFactory().CreateMaster(proxyClient);
|
||||
proxyMaster.WriteMultipleRegisters(slaveAddress: 1, startAddress: 204,
|
||||
data: new ushort[] { 10, 9876, 20 });
|
||||
|
||||
// Read raw from the simulator directly.
|
||||
using var simClient = new TcpClient();
|
||||
await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken);
|
||||
var simMaster = new ModbusFactory().CreateMaster(simClient);
|
||||
ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 204, numberOfPoints: 3);
|
||||
|
||||
raw[0].ShouldBe((ushort)10, "HR204 is not a BCD tag — must pass through unchanged");
|
||||
raw[1].ShouldBe((ushort)0x9876, "HR205 is a 16-bit BCD tag — must be re-encoded to nibbles");
|
||||
raw[2].ShouldBe((ushort)20, "HR206 is not a BCD tag — must pass through unchanged");
|
||||
}
|
||||
|
||||
// ── 8. FC16 with 32-bit BCD pair → both halves CDAB-encoded ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// FC16 covering both halves of a configured 32-bit BCD pair. The pipeline reconstructs
|
||||
/// the binary integer from the CDAB-ordered registers (binaryValue = high * 10000 + low),
|
||||
/// encodes it as a BCD pair, and writes back in CDAB order.
|
||||
///
|
||||
/// Example: client writes [low=5678, high=1234] → binaryValue = 12345678
|
||||
/// → Encode32(12345678) = (bcdLow=0x5678, bcdHigh=0x1234)
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Write_FC16_With_Bcd32Pair_StoresCdabEncoded()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
// Configure a 32-bit BCD tag spanning HR207 + HR208 (both in [200, 209] scratch range).
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(bcd32Addresses: [207]);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
// FC16 write of [low=5678, high=1234] → decimal 12345678.
|
||||
using var proxyClient = new TcpClient();
|
||||
await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var proxyMaster = new ModbusFactory().CreateMaster(proxyClient);
|
||||
proxyMaster.WriteMultipleRegisters(slaveAddress: 1, startAddress: 207,
|
||||
data: new ushort[] { 5678, 1234 });
|
||||
|
||||
using var simClient = new TcpClient();
|
||||
await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken);
|
||||
var simMaster = new ModbusFactory().CreateMaster(simClient);
|
||||
ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 207, numberOfPoints: 2);
|
||||
|
||||
raw[0].ShouldBe((ushort)0x5678, "HR207 (low word of CDAB pair) must hold low 4 BCD digits");
|
||||
raw[1].ShouldBe((ushort)0x1234, "HR208 (high word of CDAB pair) must hold high 4 BCD digits");
|
||||
}
|
||||
|
||||
// ── 9. FC16 partial overlap on 32-bit pair → raw + warning ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// FC16 writes only the LOW register of a configured 32-bit BCD pair (qty=1 at the low
|
||||
/// address). The pipeline cannot safely encode half of a 32-bit value, so it passes the
|
||||
/// register through raw and logs mbproxy.rewrite.partial_bcd.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Write_FC16_PartialBcd32_OnLowAddressOnly_PassesThroughRaw_AndLogsWarning()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var sink = new CapturingSink();
|
||||
var serilog = new LoggerConfiguration()
|
||||
.MinimumLevel.Warning()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
// Configure a 32-bit BCD tag at HR207 + HR208 (pair).
|
||||
var (proxyPort, host, cts) = await StartBcdProxyAsync(
|
||||
bcd32Addresses: [207],
|
||||
serilogOverride: serilog);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
// FC16 write of [42] to HR207 only — partial overlap on the 32-bit pair.
|
||||
using var proxyClient = new TcpClient();
|
||||
await proxyClient.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var proxyMaster = new ModbusFactory().CreateMaster(proxyClient);
|
||||
proxyMaster.WriteMultipleRegisters(slaveAddress: 1, startAddress: 207,
|
||||
data: new ushort[] { 42 });
|
||||
|
||||
// Simulator should hold the raw value 42 (no rewriting on partial overlap).
|
||||
using var simClient = new TcpClient();
|
||||
await simClient.ConnectAsync(_sim.Host, _sim.Port, TestContext.Current.CancellationToken);
|
||||
var simMaster = new ModbusFactory().CreateMaster(simClient);
|
||||
ushort[] raw = simMaster.ReadHoldingRegisters(slaveAddress: 1, startAddress: 207, numberOfPoints: 1);
|
||||
raw[0].ShouldBe((ushort)42, "Partial-overlap write must pass through raw (not BCD-encoded)");
|
||||
|
||||
// The partial_bcd warning must have been logged.
|
||||
var partialEvents = sink.Events
|
||||
.Where(e => e.MessageTemplate.Text.Contains("mbproxy.rewrite.partial_bcd")
|
||||
|| e.MessageTemplate.Text.Contains("Partial BCD overlap"))
|
||||
.ToList();
|
||||
partialEvents.ShouldNotBeEmpty("Expected mbproxy.rewrite.partial_bcd warning on partial FC16 write");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartBcdProxyAsync(
|
||||
ushort[]? bcd16Addresses = null,
|
||||
ushort[]? bcd32Addresses = null,
|
||||
Serilog.ILogger? serilogOverride = null)
|
||||
{
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
["Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
["Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
["Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
["Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
// Add BCD tag entries to the in-memory config.
|
||||
int tagIndex = 0;
|
||||
foreach (ushort addr in bcd16Addresses ?? [])
|
||||
{
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "16";
|
||||
tagIndex++;
|
||||
}
|
||||
foreach (ushort addr in bcd32Addresses ?? [])
|
||||
{
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
|
||||
config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"] = "32";
|
||||
tagIndex++;
|
||||
}
|
||||
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var host = BuildBcdProxyHost(config, serilogOverride);
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
return (proxyPort, host, runCts);
|
||||
}
|
||||
|
||||
private static IHost BuildBcdProxyHost(
|
||||
Dictionary<string, string?> config,
|
||||
Serilog.ILogger? serilogOverride = null)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
|
||||
var logger = serilogOverride
|
||||
?? new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
|
||||
|
||||
builder.Services.AddSerilog(logger, dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
// Use the real BcdPduPipeline (not NoopPduPipeline) for E2E rewriter tests.
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
builder.Services.AddHostedService<ProxyWorker>();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private sealed class AsyncHostDispose : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public AsyncHostDispose(IHost host, CancellationTokenSource cts)
|
||||
{
|
||||
_host = host;
|
||||
_cts = cts;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ }
|
||||
_host.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Capturing log sink (shared with HostSmokeTests) ─────────────────────
|
||||
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
private readonly ConcurrentQueue<LogEvent> _events = new();
|
||||
public IEnumerable<LogEvent> Events => _events;
|
||||
public void Emit(LogEvent logEvent) => _events.Enqueue(logEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Supervision;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the backend-connect Polly retry path. Phase 9 moved backend
|
||||
/// connect ownership from <c>PlcConnectionPair.CreateAsync</c> into
|
||||
/// <see cref="PlcMultiplexer"/>. These tests exercise the same Polly pipeline by driving
|
||||
/// upstream-to-multiplexer frames against a bad/intermittent backend and observing the
|
||||
/// resulting connect-success/connect-failed counters.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class BackendConnectRetryTests
|
||||
{
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static (PlcMultiplexer mux, PerPlcContext ctx) BuildMux(
|
||||
PlcOptions plc,
|
||||
ConnectionOptions connOpts,
|
||||
Polly.ResiliencePipeline pipeline)
|
||||
{
|
||||
var ctx = new PerPlcContext
|
||||
{
|
||||
PlcName = plc.Name,
|
||||
TagMap = Mbproxy.Bcd.BcdTagMap.Empty,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
};
|
||||
|
||||
var mux = new PlcMultiplexer(
|
||||
plc,
|
||||
connOpts,
|
||||
new BcdPduPipeline(),
|
||||
ctx,
|
||||
NullLoggerFactory.Instance.CreateLogger<PlcMultiplexer>(),
|
||||
pipeline);
|
||||
|
||||
return (mux, ctx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects a fresh TCP client to the proxy port and returns the accepted upstream
|
||||
/// pipe alongside the client. The caller drives a single FC03 request and observes
|
||||
/// what happens when the multiplexer attempts (and fails) to forward it.
|
||||
/// </summary>
|
||||
private static async Task<(Socket client, UpstreamPipe pipe)> AttachClientPipeAsync(
|
||||
PlcMultiplexer mux, int proxyPort, TcpListener proxyListener, string plcName)
|
||||
{
|
||||
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
|
||||
{ NoDelay = true };
|
||||
await client.ConnectAsync(IPAddress.Loopback, proxyPort);
|
||||
var upstreamSock = await proxyListener.AcceptSocketAsync();
|
||||
var pipe = new UpstreamPipe(upstreamSock, plcName, NullLogger.Instance);
|
||||
_ = Task.Run(() => mux.StartPipeAsync(pipe, CancellationToken.None));
|
||||
return (client, pipe);
|
||||
}
|
||||
|
||||
private static byte[] BuildFc03ReadFrame(ushort txId, ushort start, ushort qty, byte unitId = 1)
|
||||
=>
|
||||
[
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00, // ProtocolId
|
||||
0x00, 0x06, // Length = 6
|
||||
unitId,
|
||||
0x03, // FC03
|
||||
(byte)(start >> 8), (byte)(start & 0xFF),
|
||||
(byte)(qty >> 8), (byte)(qty & 0xFF),
|
||||
];
|
||||
|
||||
// ── Test 1: retries per pipeline on ConnectionRefused ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BackendConnect_RetriesPerPipeline_OnConnectionRefused()
|
||||
{
|
||||
int badPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [50, 100, 200] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
var connOpts = new ConnectionOptions { BackendConnectTimeoutMs = 1000, BackendRequestTimeoutMs = 3000 };
|
||||
var plcOpts = new PlcOptions { Name = "Retry3PLC", ListenPort = proxyPort, Host = "127.0.0.1", Port = badPort };
|
||||
|
||||
await using var mux = BuildMux(plcOpts, connOpts, pipeline).mux;
|
||||
|
||||
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
|
||||
proxyListener.Start();
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var (client, pipe) = await AttachClientPipeAsync(mux, proxyPort, proxyListener, plcOpts.Name);
|
||||
try
|
||||
{
|
||||
await client.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
|
||||
|
||||
// The multiplexer will Polly-retry then fail; client socket should be closed.
|
||||
var buf = new byte[1];
|
||||
int n;
|
||||
using var ctsDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
n = await client.ReceiveAsync(buf, SocketFlags.None, ctsDeadline.Token);
|
||||
break;
|
||||
}
|
||||
catch (SocketException) { n = 0; break; }
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
n.ShouldBe(0, "upstream client should observe a clean EOF after all backend attempts fail");
|
||||
sw.ElapsedMilliseconds.ShouldBeGreaterThanOrEqualTo(80,
|
||||
"Polly retries with [50,100] delays should make connect take > 80ms total");
|
||||
|
||||
var counters = (await Task.Run(() => mux.AttachedPipes)).Count; // touch state
|
||||
_ = counters; // unused — proves no race
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await pipe.DisposeAsync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
proxyListener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 2: succeeds on second attempt when backend becomes reachable ─────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BackendConnect_Succeeds_OnSecondAttempt_WhenBackendBecomesReachable()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [200, 1000, 2000] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
var connOpts = new ConnectionOptions { BackendConnectTimeoutMs = 1000, BackendRequestTimeoutMs = 3000 };
|
||||
var plcOpts = new PlcOptions { Name = "RetryOkPLC", ListenPort = proxyPort, Host = "127.0.0.1", Port = backendPort };
|
||||
|
||||
await using var muxBundle = new MuxBundle(BuildMux(plcOpts, connOpts, pipeline).mux);
|
||||
var mux = muxBundle.Mux;
|
||||
|
||||
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
|
||||
proxyListener.Start();
|
||||
|
||||
TcpListener? backendListener = null;
|
||||
Socket? acceptedBackend = null;
|
||||
Task<Socket>? acceptTask = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Start the backend listener after 250 ms — within the first backoff window.
|
||||
var startBackendTask = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(250, CancellationToken.None);
|
||||
backendListener = new TcpListener(IPAddress.Loopback, backendPort);
|
||||
backendListener.Start();
|
||||
acceptTask = backendListener.AcceptSocketAsync(CancellationToken.None).AsTask();
|
||||
}, CancellationToken.None);
|
||||
|
||||
var (client, pipe) = await AttachClientPipeAsync(mux, proxyPort, proxyListener, plcOpts.Name);
|
||||
try
|
||||
{
|
||||
// Drive a request — this triggers backend connect.
|
||||
await client.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
|
||||
|
||||
await startBackendTask;
|
||||
acceptedBackend = await acceptTask!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
// The multiplexer's counters should reflect a successful connect.
|
||||
using var pollCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!pollCts.IsCancellationRequested
|
||||
&& mux.AttachedPipes.Count == 0)
|
||||
{
|
||||
await Task.Delay(20, pollCts.Token);
|
||||
}
|
||||
mux.AttachedPipes.Count.ShouldBeGreaterThanOrEqualTo(1,
|
||||
"the upstream pipe should remain attached after a successful backend connect");
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await pipe.DisposeAsync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
proxyListener.Stop();
|
||||
acceptedBackend?.Dispose();
|
||||
backendListener?.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 3: all attempts fail → upstream socket is closed ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BackendConnect_AllAttemptsFail_ClosesUpstream()
|
||||
{
|
||||
int badPort = PickFreePort();
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var profile = new RetryProfile { MaxAttempts = 2, BackoffMs = [50, 100] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
var connOpts = new ConnectionOptions { BackendConnectTimeoutMs = 500, BackendRequestTimeoutMs = 3000 };
|
||||
var plcOpts = new PlcOptions { Name = "FailPLC", ListenPort = proxyPort, Host = "127.0.0.1", Port = badPort };
|
||||
|
||||
var muxResult = BuildMux(plcOpts, connOpts, pipeline);
|
||||
await using var mux = muxResult.mux;
|
||||
|
||||
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
|
||||
proxyListener.Start();
|
||||
try
|
||||
{
|
||||
var (client, pipe) = await AttachClientPipeAsync(mux, proxyPort, proxyListener, plcOpts.Name);
|
||||
try
|
||||
{
|
||||
await client.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
|
||||
|
||||
var buf = new byte[1];
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = await client.ReceiveAsync(buf, SocketFlags.None, deadline.Token);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
n = 0;
|
||||
}
|
||||
n.ShouldBe(0, "upstream socket should observe a clean EOF after all attempts fail");
|
||||
|
||||
muxResult.ctx.Counters.Snapshot().ConnectsFailed.ShouldBeGreaterThanOrEqualTo(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
client.Dispose();
|
||||
await pipe.DisposeAsync();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
proxyListener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper that lets the test scope-await both <see cref="PlcMultiplexer"/> disposal
|
||||
/// and capture of the public surface in a single using block.
|
||||
/// </summary>
|
||||
private sealed class MuxBundle : IAsyncDisposable
|
||||
{
|
||||
public PlcMultiplexer Mux { get; }
|
||||
public MuxBundle(PlcMultiplexer mux) => Mux = mux;
|
||||
public ValueTask DisposeAsync() => Mux.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Supervision;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="PolicyFactory"/>. No network, no simulator.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class PolicyFactoryTests
|
||||
{
|
||||
// ── 1. BuildBackendConnect: default 3-attempt pipeline ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildBackendConnect_ProducesPipeline_With3Attempts_Default()
|
||||
{
|
||||
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [100, 500, 2000] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
// The pipeline should exist and be usable.
|
||||
int attempts = 0;
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new SocketException((int)SocketError.ConnectionRefused);
|
||||
}, CancellationToken.None));
|
||||
|
||||
// 3 total attempts: 1 initial + 2 retries.
|
||||
Assert.Equal(3, attempts);
|
||||
}
|
||||
|
||||
// ── 2. BuildBackendConnect: delay sequence matches BackoffMs ────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildBackendConnect_Backoff_MatchesConfig()
|
||||
{
|
||||
// Use a short backoff so the test runs fast.
|
||||
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [50, 100, 200] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
// Record the wall-clock timestamps of each attempt to infer delays.
|
||||
var timestamps = new List<DateTime>();
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
timestamps.Add(DateTime.UtcNow);
|
||||
await Task.Yield();
|
||||
throw new SocketException((int)SocketError.ConnectionRefused);
|
||||
}, CancellationToken.None));
|
||||
|
||||
Assert.Equal(3, timestamps.Count);
|
||||
|
||||
// Delay between attempt 0→1 should be ≥ 50 ms (allow generous tolerance for CI).
|
||||
double delay01 = (timestamps[1] - timestamps[0]).TotalMilliseconds;
|
||||
Assert.True(delay01 >= 40, $"Expected delay ≥ 40ms between attempt 0 and 1, got {delay01:F0}ms");
|
||||
|
||||
// Delay between attempt 1→2 should be ≥ 100 ms.
|
||||
double delay12 = (timestamps[2] - timestamps[1]).TotalMilliseconds;
|
||||
Assert.True(delay12 >= 80, $"Expected delay ≥ 80ms between attempt 1 and 2, got {delay12:F0}ms");
|
||||
}
|
||||
|
||||
// ── 3. BuildListenerRecovery: initial-backoff then steady-state ──────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildListenerRecovery_InitialBackoffFollowedBySteadyState()
|
||||
{
|
||||
// Use very short delays so the test runs fast.
|
||||
var profile = new RecoveryProfile
|
||||
{
|
||||
InitialBackoffMs = [10, 20, 30], // 3-element initial array
|
||||
SteadyStateMs = 50,
|
||||
};
|
||||
var pipeline = PolicyFactory.BuildListenerRecovery(profile, NullLogger.Instance);
|
||||
|
||||
// Collect the delay values Polly would use for 7 retries (more than the initial array).
|
||||
var delays = new List<TimeSpan>();
|
||||
int maxRuns = 8; // 1 initial + 7 retries
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
int runs = 0;
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(async () =>
|
||||
await pipeline.ExecuteAsync(async token =>
|
||||
{
|
||||
runs++;
|
||||
await Task.Yield();
|
||||
if (runs < maxRuns)
|
||||
throw new InvalidOperationException("simulate fault");
|
||||
// Last run: cancel the token to exit cleanly.
|
||||
throw new OperationCanceledException(token);
|
||||
}, cts.Token));
|
||||
|
||||
// We can't easily intercept the per-delay values from inside the pipeline,
|
||||
// so we verify the timing instead. Just assert the run count was reached
|
||||
// and that the pipeline retried until the OperationCanceledException.
|
||||
// The key contract: MaxRetryAttempts = int.MaxValue (runs indefinitely).
|
||||
Assert.True(runs >= maxRuns - 1, $"Expected at least {maxRuns - 1} runs; got {runs}");
|
||||
}
|
||||
|
||||
// ── 4. BuildBackendConnect: no retry on non-transient exceptions ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildBackendConnect_NoRetry_OnNonTransientException()
|
||||
{
|
||||
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [100, 500, 2000] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
int attempts = 0;
|
||||
|
||||
// ArgumentException is not a transient socket error — pipeline should NOT retry it.
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new ArgumentException("bad argument");
|
||||
}, CancellationToken.None));
|
||||
|
||||
// Only the first attempt should have run — no retries.
|
||||
Assert.Equal(1, attempts);
|
||||
}
|
||||
|
||||
// ── 5. BuildBackendConnect: retries ConnectionRefused but not WSAEACCES ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BuildBackendConnect_Retries_ConnectionRefused_Not_SocketError_Access()
|
||||
{
|
||||
var profile = new RetryProfile { MaxAttempts = 2, BackoffMs = [10] };
|
||||
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
|
||||
|
||||
// SocketError.AccessDenied is NOT in the retryable set.
|
||||
int attempts = 0;
|
||||
|
||||
await Assert.ThrowsAsync<SocketException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
attempts++;
|
||||
await Task.Yield();
|
||||
throw new SocketException((int)SocketError.AccessDenied);
|
||||
}, CancellationToken.None));
|
||||
|
||||
Assert.Equal(1, attempts); // Should not retry AccessDenied.
|
||||
|
||||
// Now verify ConnectionRefused IS retried.
|
||||
int refusedAttempts = 0;
|
||||
await Assert.ThrowsAsync<SocketException>(async () =>
|
||||
await pipeline.ExecuteAsync(async _ =>
|
||||
{
|
||||
refusedAttempts++;
|
||||
await Task.Yield();
|
||||
throw new SocketException((int)SocketError.ConnectionRefused);
|
||||
}, CancellationToken.None));
|
||||
|
||||
Assert.Equal(2, refusedAttempts); // 1 initial + 1 retry (MaxAttempts=2).
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Polly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Supervision;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end supervisor tests that run the proxy against the DL205 simulator.
|
||||
/// These tests verify supervisor-level behaviour (recovery, counters) with a real
|
||||
/// Modbus backend rather than a bare socket.
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class SupervisorE2ETests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
|
||||
public SupervisorE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
|
||||
{
|
||||
_sim = sim;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private PlcListenerSupervisor BuildSimSupervisor(
|
||||
int listenPort,
|
||||
RecoveryProfile? recoveryProfile = null)
|
||||
{
|
||||
var profile = recoveryProfile ?? new RecoveryProfile
|
||||
{
|
||||
InitialBackoffMs = [200, 200],
|
||||
SteadyStateMs = 200,
|
||||
};
|
||||
|
||||
ILoggerFactory loggerFactory = NullLoggerFactory.Instance;
|
||||
|
||||
var plcOpts = new PlcOptions
|
||||
{
|
||||
Name = "SimPLC",
|
||||
ListenPort = listenPort,
|
||||
Host = _sim.Host,
|
||||
Port = _sim.Port,
|
||||
};
|
||||
var connOpts = new ConnectionOptions
|
||||
{
|
||||
BackendConnectTimeoutMs = 3000,
|
||||
BackendRequestTimeoutMs = 3000,
|
||||
};
|
||||
|
||||
var recoveryPipeline = PolicyFactory.BuildListenerRecovery(profile, NullLogger.Instance);
|
||||
var backendPipeline = PolicyFactory.BuildBackendConnect(
|
||||
new RetryProfile { MaxAttempts = 2, BackoffMs = [100, 500] },
|
||||
NullLogger.Instance);
|
||||
|
||||
return new PlcListenerSupervisor(
|
||||
plc: plcOpts,
|
||||
connectionOptions: connOpts,
|
||||
pipeline: new NoopPduPipeline(),
|
||||
listenerLogger: loggerFactory.CreateLogger<PlcListener>(),
|
||||
multiplexerLogger: loggerFactory.CreateLogger<Mbproxy.Proxy.Multiplexing.PlcMultiplexer>(),
|
||||
pipeLogger: loggerFactory.CreateLogger("Mbproxy.Proxy.UpstreamPipe.Test"),
|
||||
perPlcContext: null,
|
||||
recoveryPipeline: recoveryPipeline,
|
||||
logger: loggerFactory.CreateLogger<PlcListenerSupervisor>(),
|
||||
backendConnectPipeline: backendPipeline);
|
||||
}
|
||||
|
||||
// ── E2E 1: Recovery when blocking listener releases port ──────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_Recovery_When_BlockingListenerReleasesPort()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int listenPort = PickFreePort();
|
||||
|
||||
// Block the port before starting the supervisor.
|
||||
var blocker = new TcpListener(IPAddress.Any, listenPort);
|
||||
blocker.Start();
|
||||
|
||||
await using var supervisor = BuildSimSupervisor(listenPort);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
|
||||
// Wait for first bind attempt to fail.
|
||||
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
|
||||
Assert.Equal(SupervisorState.Recovering, supervisor.Snapshot().State);
|
||||
|
||||
// Release the port.
|
||||
blocker.Stop();
|
||||
|
||||
// Poll for up to 3 s for the supervisor to bind.
|
||||
using var recoveryCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (!recoveryCts.IsCancellationRequested)
|
||||
{
|
||||
if (supervisor.Snapshot().State == SupervisorState.Bound)
|
||||
break;
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
Assert.Equal(SupervisorState.Bound, supervisor.Snapshot().State);
|
||||
|
||||
// Verify the proxy actually serves traffic by connecting to it.
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", listenPort, cts.Token);
|
||||
|
||||
// Send a minimal FC03 request (read 1 register at address 0).
|
||||
var req = new byte[]
|
||||
{
|
||||
0x00, 0x01, // TxId
|
||||
0x00, 0x00, // ProtocolId
|
||||
0x00, 0x06, // Length (6)
|
||||
0x01, // UnitId
|
||||
0x03, // FC03
|
||||
0x00, 0x00, // Start address 0
|
||||
0x00, 0x01, // Qty 1
|
||||
};
|
||||
await client.GetStream().WriteAsync(req, cts.Token);
|
||||
|
||||
// Read at least 9 bytes (7 header + 2 data minimum for FC03 with 1 register).
|
||||
var rsp = new byte[260];
|
||||
int read = 0;
|
||||
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (read < 9 && !readCts.IsCancellationRequested)
|
||||
read += await client.GetStream().ReadAsync(rsp.AsMemory(read), readCts.Token);
|
||||
|
||||
// Verify we got a response with matching TxId.
|
||||
Assert.True(read >= 9, $"Expected ≥ 9 bytes, got {read}");
|
||||
Assert.Equal(0x00, rsp[0]); // TxId high
|
||||
Assert.Equal(0x01, rsp[1]); // TxId low
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
}
|
||||
|
||||
// ── E2E 2: RecoveryAttempts counter increments and is visible on Snapshot ─────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_RecoveryAttempts_CounterIncrements_Visible_OnSnapshot()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int listenPort = PickFreePort();
|
||||
|
||||
// Block the port so the supervisor enters recovery.
|
||||
var blocker = new TcpListener(IPAddress.Any, listenPort);
|
||||
blocker.Start();
|
||||
|
||||
// Use short delays to get multiple recovery attempts quickly.
|
||||
var profile = new RecoveryProfile
|
||||
{
|
||||
InitialBackoffMs = [100, 100, 100],
|
||||
SteadyStateMs = 100,
|
||||
};
|
||||
|
||||
await using var supervisor = BuildSimSupervisor(listenPort, profile);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
|
||||
|
||||
// Wait for multiple recovery attempts to accumulate.
|
||||
await Task.Delay(600, TestContext.Current.CancellationToken); // ~6 × 100 ms attempts
|
||||
|
||||
var snap = supervisor.Snapshot();
|
||||
Assert.Equal(SupervisorState.Recovering, snap.State);
|
||||
Assert.True(snap.RecoveryAttempts >= 2,
|
||||
$"Expected ≥ 2 recovery attempts after 600ms with 100ms backoff; got {snap.RecoveryAttempts}");
|
||||
Assert.NotNull(snap.LastBindError);
|
||||
|
||||
// Release the port and verify recovery.
|
||||
blocker.Stop();
|
||||
|
||||
using var recoveryCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (!recoveryCts.IsCancellationRequested)
|
||||
{
|
||||
if (supervisor.Snapshot().State == SupervisorState.Bound)
|
||||
break;
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
Assert.Equal(SupervisorState.Bound, supervisor.Snapshot().State);
|
||||
|
||||
// RecoveryAttempts must still be the accumulated value (not reset to 0).
|
||||
var afterSnap = supervisor.Snapshot();
|
||||
Assert.True(afterSnap.RecoveryAttempts >= snap.RecoveryAttempts,
|
||||
$"RecoveryAttempts should accumulate; was {snap.RecoveryAttempts}, now {afterSnap.RecoveryAttempts}");
|
||||
|
||||
// LastBindError should be cleared after a successful bind.
|
||||
Assert.Null(afterSnap.LastBindError);
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Polly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Supervision;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="PlcListenerSupervisor"/> using real sockets.
|
||||
/// No simulator required — these tests drive bind/recover cycles directly.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SupervisorTests
|
||||
{
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static PlcOptions MakePlcOptions(int listenPort) => new()
|
||||
{
|
||||
Name = "TestPLC",
|
||||
ListenPort = listenPort,
|
||||
Host = "127.0.0.1",
|
||||
Port = 502,
|
||||
};
|
||||
|
||||
private static ConnectionOptions MakeConnectionOptions() => new()
|
||||
{
|
||||
BackendConnectTimeoutMs = 500,
|
||||
BackendRequestTimeoutMs = 3000,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds a recovery pipeline with very short delays (suitable for tests).
|
||||
/// </summary>
|
||||
private static ResiliencePipeline FastRecoveryPipeline(int initialMs = 100, int steadyMs = 100)
|
||||
{
|
||||
var profile = new RecoveryProfile
|
||||
{
|
||||
InitialBackoffMs = [initialMs, initialMs],
|
||||
SteadyStateMs = steadyMs,
|
||||
};
|
||||
return PolicyFactory.BuildListenerRecovery(profile, NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static PlcListenerSupervisor BuildSupervisor(
|
||||
int port,
|
||||
ResiliencePipeline? pipeline = null)
|
||||
{
|
||||
ILoggerFactory loggerFactory = NullLoggerFactory.Instance;
|
||||
return new PlcListenerSupervisor(
|
||||
plc: MakePlcOptions(port),
|
||||
connectionOptions: MakeConnectionOptions(),
|
||||
pipeline: new NoopPduPipeline(),
|
||||
listenerLogger: loggerFactory.CreateLogger<PlcListener>(),
|
||||
multiplexerLogger: loggerFactory.CreateLogger<Mbproxy.Proxy.Multiplexing.PlcMultiplexer>(),
|
||||
pipeLogger: loggerFactory.CreateLogger("Mbproxy.Proxy.UpstreamPipe.Test"),
|
||||
perPlcContext: null,
|
||||
recoveryPipeline: pipeline ?? FastRecoveryPipeline(),
|
||||
logger: loggerFactory.CreateLogger<PlcListenerSupervisor>(),
|
||||
backendConnectPipeline: null);
|
||||
}
|
||||
|
||||
// ── Test 1: starts listener and transitions to Bound ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Supervisor_StartsListener_AndTransitionsToBound()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
await using var supervisor = BuildSupervisor(port);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
|
||||
// Wait for initial bind attempt to complete.
|
||||
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
|
||||
|
||||
var snapshot = supervisor.Snapshot();
|
||||
Assert.Equal(SupervisorState.Bound, snapshot.State);
|
||||
Assert.Null(snapshot.LastBindError);
|
||||
Assert.Equal(0, snapshot.RecoveryAttempts);
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
Assert.Equal(SupervisorState.Stopped, supervisor.Snapshot().State);
|
||||
}
|
||||
|
||||
// ── Test 2: port in use → transitions to Recovering ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Supervisor_StartFails_WhenPortInUse_TransitionsToRecovering()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
|
||||
// Occupy the port BEFORE the supervisor tries to bind.
|
||||
var blocker = new TcpListener(IPAddress.Any, port);
|
||||
blocker.Start();
|
||||
try
|
||||
{
|
||||
await using var supervisor = BuildSupervisor(port);
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
|
||||
// Wait up to 2 s for the supervisor to attempt and fail the bind.
|
||||
using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await supervisor.WaitForInitialBindAttemptAsync(waitCts.Token);
|
||||
|
||||
var snapshot = supervisor.Snapshot();
|
||||
Assert.Equal(SupervisorState.Recovering, snapshot.State);
|
||||
Assert.NotNull(snapshot.LastBindError);
|
||||
Assert.True(snapshot.RecoveryAttempts >= 1,
|
||||
$"Expected RecoveryAttempts >= 1, got {snapshot.RecoveryAttempts}");
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
blocker.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 3: recovers when port frees ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Supervisor_Recovers_WhenPortFrees()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
|
||||
// Occupy the port.
|
||||
var blocker = new TcpListener(IPAddress.Any, port);
|
||||
blocker.Start();
|
||||
|
||||
// Use a fast initial backoff of 200 ms so recovery is quick.
|
||||
var pipeline = FastRecoveryPipeline(initialMs: 200, steadyMs: 200);
|
||||
await using var supervisor = BuildSupervisor(port, pipeline);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
|
||||
// Wait for the supervisor to enter Recovering.
|
||||
using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await supervisor.WaitForInitialBindAttemptAsync(waitCts.Token);
|
||||
Assert.Equal(SupervisorState.Recovering, supervisor.Snapshot().State);
|
||||
|
||||
// Release the port — the supervisor should bind on its next retry (≤ 200 ms + slack).
|
||||
blocker.Stop();
|
||||
|
||||
// Poll for up to 3 s for the supervisor to reach Bound.
|
||||
using var recoveryCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
while (!recoveryCts.IsCancellationRequested)
|
||||
{
|
||||
if (supervisor.Snapshot().State == SupervisorState.Bound)
|
||||
break;
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
Assert.Equal(SupervisorState.Bound, supervisor.Snapshot().State);
|
||||
Assert.True(supervisor.Snapshot().RecoveryAttempts >= 1,
|
||||
"RecoveryAttempts should be ≥ 1 after at least one failed bind");
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
}
|
||||
|
||||
// ── Test 4: runtime fault triggers recovery ──────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Supervisor_RuntimeFault_TriggersRecovery()
|
||||
{
|
||||
// This test verifies that a supervisor that starts successfully stays Bound
|
||||
// and that recovery mechanics are wired. For a full runtime-fault scenario,
|
||||
// see the E2E tests. Here we verify:
|
||||
// 1. Supervisor reaches Bound.
|
||||
// 2. After StopAsync, transitions to Stopped.
|
||||
// 3. RecoveryAttempts is 0 when no fault occurred.
|
||||
|
||||
int port = PickFreePort();
|
||||
var pipeline = FastRecoveryPipeline(initialMs: 100, steadyMs: 100);
|
||||
await using var supervisor = BuildSupervisor(port, pipeline);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
|
||||
Assert.Equal(SupervisorState.Bound, supervisor.Snapshot().State);
|
||||
|
||||
var snap = supervisor.Snapshot();
|
||||
Assert.Equal(SupervisorState.Bound, snap.State);
|
||||
Assert.Equal(0, snap.RecoveryAttempts);
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
Assert.Equal(SupervisorState.Stopped, supervisor.Snapshot().State);
|
||||
}
|
||||
|
||||
// ── Test 5: StopAsync while in Recovering does not hang ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Supervisor_Stop_CleanlyTransitionsTo_Stopped_AndCancelsRetry()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
|
||||
// Occupy the port so the supervisor stays in Recovering.
|
||||
var blocker = new TcpListener(IPAddress.Any, port);
|
||||
blocker.Start();
|
||||
try
|
||||
{
|
||||
// Use a very long steady-state delay to prove StopAsync cuts through it.
|
||||
var profile = new RecoveryProfile
|
||||
{
|
||||
InitialBackoffMs = [100], // short initial
|
||||
SteadyStateMs = 30_000, // 30 s — if StopAsync doesn't cancel, test times out
|
||||
};
|
||||
var pipeline = PolicyFactory.BuildListenerRecovery(profile, NullLogger.Instance);
|
||||
|
||||
await using var supervisor = BuildSupervisor(port, pipeline);
|
||||
using var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
await supervisor.StartAsync(runCts.Token);
|
||||
|
||||
// Wait for the supervisor to enter Recovering (failed first bind).
|
||||
using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await supervisor.WaitForInitialBindAttemptAsync(waitCts.Token);
|
||||
Assert.Equal(SupervisorState.Recovering, supervisor.Snapshot().State);
|
||||
|
||||
// Wait a tiny bit to ensure Polly has started the steady-state delay.
|
||||
await Task.Delay(250, TestContext.Current.CancellationToken);
|
||||
|
||||
// StopAsync must return within ~2 s, NOT wait out the 30 s backoff.
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await supervisor.StopAsync(stopCts.Token);
|
||||
|
||||
Assert.Equal(SupervisorState.Stopped, supervisor.Snapshot().State);
|
||||
}
|
||||
finally
|
||||
{
|
||||
blocker.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 6: RecoveryAttempts accumulates over lifetime ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Supervisor_RecoveryAttempts_AccumulateOverLifetime()
|
||||
{
|
||||
int port = PickFreePort();
|
||||
|
||||
// Occupy the port initially.
|
||||
var blocker = new TcpListener(IPAddress.Any, port);
|
||||
blocker.Start();
|
||||
|
||||
var pipeline = FastRecoveryPipeline(initialMs: 100, steadyMs: 100);
|
||||
await using var supervisor = BuildSupervisor(port, pipeline);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
await supervisor.StartAsync(cts.Token);
|
||||
|
||||
// Wait for first recovery attempt.
|
||||
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
|
||||
Assert.Equal(SupervisorState.Recovering, supervisor.Snapshot().State);
|
||||
|
||||
// Wait for a couple more retry cycles (each ~100 ms).
|
||||
await Task.Delay(400, TestContext.Current.CancellationToken);
|
||||
|
||||
int midCount = supervisor.Snapshot().RecoveryAttempts;
|
||||
Assert.True(midCount >= 1, $"Expected ≥ 1 recovery attempt, got {midCount}");
|
||||
|
||||
// Now release the port so the supervisor can recover.
|
||||
blocker.Stop();
|
||||
await Task.Delay(500, TestContext.Current.CancellationToken);
|
||||
|
||||
// Verify RecoveryAttempts did NOT reset to 0 after recovery.
|
||||
// It should still show the same value or higher (if another retry happened).
|
||||
int afterCount = supervisor.Snapshot().RecoveryAttempts;
|
||||
Assert.True(afterCount >= midCount,
|
||||
$"RecoveryAttempts should accumulate (was {midCount}, now {afterCount})");
|
||||
|
||||
await supervisor.StopAsync(cts.Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user