56eee3c563
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>
150 lines
4.7 KiB
C#
150 lines
4.7 KiB
C#
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);
|
|
}
|
|
}
|