mbproxy: add in-flight read coalescing (Phase 10)
When two or more upstream clients send the same FC03/FC04 read while a matching request is already in flight on the same PLC's multiplexed backend socket, attach the late arrivals to the existing InFlightRequest .InterestedParties list instead of opening a second backend round-trip. The single backend response fans out to every attached party with each party's original MBAP TxId restored individually. Zero post-response staleness — coalescing operates entirely within the in-flight window (microseconds to ~10 ms typical); the proxy is NOT a cache layer. Headline mechanism: - New record struct CoalescingKey(UnitId, Fc, StartAddress, Qty) keys the per-PLC InFlightByKeyMap. FC03 and FC04 are separate Modbus tables and never share a key; different unit IDs never coalesce; writes (FC06/FC16) bypass the coalescing path entirely. - InFlightByKeyMap uses a simple lock around a Dictionary; atomic TryAttachOrCreate either appends a new party to the in-flight request's mutable List<InterestedParty> or invokes a factory to build a fresh entry. Per-entry MaxParties cap (default 32) bounds fan-out cost; past the cap, the next arrival opens a new entry. - PlcMultiplexer.OnUpstreamFrameAsync takes the coalescing path for FC03/FC04 when Mbproxy.Resilience.ReadCoalescing.Enabled. The factory closure does the Phase-9 work (allocate TxId, add to CorrelationMap); the channel send happens AFTER returning from TryAttachOrCreate so the map lock is not held across the async send. - Response fan-out in RunBackendReaderAsync removes the entry from InFlightByKeyMap before iterating InterestedParties, ensuring no concurrent attach can mutate the list during iteration. - Cascade + watchdog paths also drain the key map so a stale entry cannot outlive its backend round-trip. Counter accounting balance (per snapshot): CoalescedHitCount + CoalescedMissCount equals total FC03 + FC04 requests since startup. Even with coalescing disabled, every read still bumps Miss so dashboard math stays balanced. New surface (additive only): - src/Mbproxy/Proxy/Multiplexing/CoalescingKey.cs - src/Mbproxy/Proxy/Multiplexing/InFlightByKeyMap.cs - src/Mbproxy/Proxy/Multiplexing/CoalescingLogEvents.cs - ReadCoalescingOptions on ResilienceOptions - CoalescedHitCount / CoalescedMissCount / CoalescedResponseToDeadUpstream counters surfaced on /status.json per PLC and as a compact "Coal" cell on the HTML status page. Phase 9 test patch: TwoUpstreams_ProxyTxIds_AreDistinct_OnTheWire previously read the same register from both clients (which now coalesces). Patched to read two different addresses so the test still proves distinct backend TxIds without violating the coalescing contract. Tests added: 24 new (19 unit + 5 E2E): - CoalescingKeyTests (5) - InFlightByKeyMapTests (6, includes concurrent stress) - ReadCoalescingTests (8, stub-backend with deterministic delay) - ReadCoalescingE2ETests (5, pymodbus simulator; coalescing-active during overlap is proven against the stub, not the sim, due to pymodbus 3.13's known concurrent-frame bug) Total: 325 tests passing (282 unit + 43 E2E). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,9 @@ public sealed class StatusHtmlRendererTests
|
||||
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0),
|
||||
LastRoundTripMs: 3.5,
|
||||
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
|
||||
DisconnectCascades: 0, QueueDepth: 0),
|
||||
DisconnectCascades: 0, QueueDepth: 0,
|
||||
CoalescedHitCount: 0, CoalescedMissCount: 0,
|
||||
CoalescedResponseToDeadUpstream: 0),
|
||||
Bytes: new PlcBytesStatus(1024, 2048));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Equality + hash-distribution coverage for the Phase-10 <see cref="CoalescingKey"/>
|
||||
/// record struct. The key is the load-bearing primitive of read coalescing: bad equality
|
||||
/// would either cause unrelated requests to share a backend round-trip (correctness loss)
|
||||
/// or prevent legitimate same-key requests from coalescing (performance loss).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CoalescingKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equality_OnIdenticalKeys_ReturnsTrue()
|
||||
{
|
||||
var a = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
||||
var b = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
||||
|
||||
a.ShouldBe(b, "identical keys must compare equal");
|
||||
a.GetHashCode().ShouldBe(b.GetHashCode(), "identical keys must hash to the same bucket");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_OnDifferentFc_ReturnsFalse()
|
||||
{
|
||||
// FC03 (Read Holding Registers) and FC04 (Read Input Registers) name DIFFERENT
|
||||
// Modbus tables even for the same address. Coalescing them would deliver wrong
|
||||
// data.
|
||||
var fc03 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
||||
var fc04 = new CoalescingKey(UnitId: 1, Fc: 0x04, StartAddress: 100, Qty:4);
|
||||
|
||||
fc03.ShouldNotBe(fc04, "FC03 and FC04 keys must never coalesce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_OnDifferentUnitId_ReturnsFalse()
|
||||
{
|
||||
// Different unit IDs typically address different PLC personalities behind a shared
|
||||
// socket (multi-drop / gateway-backed setups). Never coalesce across them.
|
||||
var u1 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
||||
var u2 = new CoalescingKey(UnitId: 2, Fc: 0x03, StartAddress: 100, Qty:4);
|
||||
|
||||
u1.ShouldNotBe(u2, "different unit IDs must never coalesce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_OnDifferentQty_ReturnsFalse()
|
||||
{
|
||||
var read1 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:1);
|
||||
var read4 = new CoalescingKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty:4);
|
||||
|
||||
read1.ShouldNotBe(read4, "different qty must not coalesce — response register count differs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashCode_DistributionSanity()
|
||||
{
|
||||
// Build 10,000 keys at random V-memory-ish addresses and bucket the low byte of
|
||||
// GetHashCode. A reasonable hash should spread fairly evenly across 256 buckets.
|
||||
// Threshold: no single bucket holds > 5% of total (well above ideal 1/256 = 0.4%).
|
||||
const int Count = 10_000;
|
||||
var rng = new Random(17);
|
||||
var buckets = new int[256];
|
||||
|
||||
for (int i = 0; i < Count; i++)
|
||||
{
|
||||
ushort start = (ushort)rng.Next(0, 4096); // 12-bit V-memory space
|
||||
ushort qty = (ushort)rng.Next(1, 128);
|
||||
byte unit = (byte)rng.Next(0, 4);
|
||||
byte fc = rng.Next(2) == 0 ? (byte)0x03 : (byte)0x04;
|
||||
|
||||
int bucket = new CoalescingKey(unit, fc, start, qty).GetHashCode() & 0xFF;
|
||||
buckets[bucket]++;
|
||||
}
|
||||
|
||||
int max = 0;
|
||||
for (int i = 0; i < buckets.Length; i++) if (buckets[i] > max) max = buckets[i];
|
||||
|
||||
int ceiling = Count * 5 / 100;
|
||||
max.ShouldBeLessThanOrEqualTo(ceiling,
|
||||
$"hash distribution is uneven — the busiest bucket holds {max} > {ceiling} keys");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase-10 <see cref="InFlightByKeyMap"/>. Covers the atomic
|
||||
/// attach-or-create primitive (load-bearing concurrency invariant), the per-entry
|
||||
/// max-parties cap (load-shedding safety valve), and concurrent attach correctness.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InFlightByKeyMapTests
|
||||
{
|
||||
private static UpstreamPipe MakePipe()
|
||||
{
|
||||
// The map only retains references to InterestedParty; it never reads pipe state.
|
||||
// A connected loopback socket satisfies the constructor contract.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var c = new TcpClient();
|
||||
c.Connect(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port);
|
||||
var s = listener.AcceptSocket();
|
||||
listener.Stop();
|
||||
return new UpstreamPipe(s, "PLC1", NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static InFlightRequest MakeRequest(InterestedParty party, byte fc = 0x03,
|
||||
ushort start = 100, ushort qty = 1, byte unit = 1)
|
||||
{
|
||||
// The factory uses a mutable List<InterestedParty> so the map can append on attach.
|
||||
var list = new List<InterestedParty>(capacity: 1) { party };
|
||||
return new InFlightRequest(
|
||||
UnitId: unit,
|
||||
Fc: fc,
|
||||
StartAddress: start,
|
||||
Qty: qty,
|
||||
InterestedParties: list,
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAttachOrCreate_NewKey_CallsFactory_ReturnsTrue_WasNewTrue()
|
||||
{
|
||||
var pipe = MakePipe();
|
||||
try
|
||||
{
|
||||
var map = new InFlightByKeyMap();
|
||||
var key = new CoalescingKey(1, 0x03, 100, 1);
|
||||
var party = new InterestedParty(pipe, OriginalTxId: 0x1234);
|
||||
|
||||
int factoryCalls = 0;
|
||||
bool ok = map.TryAttachOrCreate(
|
||||
key, party,
|
||||
factory: () => { factoryCalls++; return MakeRequest(party); },
|
||||
maxParties: 32,
|
||||
out var req, out bool wasNew);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
wasNew.ShouldBeTrue("a brand-new key must take the create branch");
|
||||
factoryCalls.ShouldBe(1, "the factory must be called exactly once");
|
||||
req.ShouldNotBeNull();
|
||||
req.InterestedParties.Count.ShouldBe(1);
|
||||
map.Count.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipe.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAttachOrCreate_ExistingKey_AppendsParty_ReturnsTrue_WasNewFalse()
|
||||
{
|
||||
var pipeA = MakePipe();
|
||||
var pipeB = MakePipe();
|
||||
try
|
||||
{
|
||||
var map = new InFlightByKeyMap();
|
||||
var key = new CoalescingKey(1, 0x03, 100, 1);
|
||||
|
||||
var partyA = new InterestedParty(pipeA, OriginalTxId: 0x1111);
|
||||
var partyB = new InterestedParty(pipeB, OriginalTxId: 0x2222);
|
||||
|
||||
int factoryCalls = 0;
|
||||
map.TryAttachOrCreate(key, partyA,
|
||||
factory: () => { factoryCalls++; return MakeRequest(partyA); },
|
||||
maxParties: 32, out var first, out bool firstWasNew);
|
||||
|
||||
bool ok = map.TryAttachOrCreate(key, partyB,
|
||||
factory: () => { factoryCalls++; return MakeRequest(partyB); },
|
||||
maxParties: 32, out var second, out bool secondWasNew);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
firstWasNew.ShouldBeTrue();
|
||||
secondWasNew.ShouldBeFalse("the second attach must coalesce onto the first");
|
||||
factoryCalls.ShouldBe(1, "the factory must only fire on the create branch");
|
||||
second.ShouldBeSameAs(first, "both attaches must return the same InFlightRequest reference");
|
||||
second.InterestedParties.Count.ShouldBe(2, "the second party must be appended in place");
|
||||
second.InterestedParties[0].OriginalTxId.ShouldBe((ushort)0x1111);
|
||||
second.InterestedParties[1].OriginalTxId.ShouldBe((ushort)0x2222);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipeA.DisposeAsync();
|
||||
await pipeB.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAttachOrCreate_ExistingKey_AtMaxParties_CreatesFreshEntry_NotAppend()
|
||||
{
|
||||
var pipeA = MakePipe();
|
||||
var pipeB = MakePipe();
|
||||
var pipeC = MakePipe();
|
||||
try
|
||||
{
|
||||
var map = new InFlightByKeyMap();
|
||||
var key = new CoalescingKey(1, 0x03, 100, 1);
|
||||
|
||||
var partyA = new InterestedParty(pipeA, OriginalTxId: 0xAAAA);
|
||||
var partyB = new InterestedParty(pipeB, OriginalTxId: 0xBBBB);
|
||||
var partyC = new InterestedParty(pipeC, OriginalTxId: 0xCCCC);
|
||||
|
||||
// MaxParties = 2 — first attach creates, second appends, third overflows.
|
||||
map.TryAttachOrCreate(key, partyA,
|
||||
factory: () => MakeRequest(partyA), maxParties: 2,
|
||||
out var first, out _);
|
||||
map.TryAttachOrCreate(key, partyB,
|
||||
factory: () => MakeRequest(partyB), maxParties: 2,
|
||||
out var second, out _);
|
||||
|
||||
int factoryCalls = 0;
|
||||
bool ok = map.TryAttachOrCreate(key, partyC,
|
||||
factory: () => { factoryCalls++; return MakeRequest(partyC); },
|
||||
maxParties: 2,
|
||||
out var third, out bool thirdWasNew);
|
||||
|
||||
ok.ShouldBeTrue();
|
||||
thirdWasNew.ShouldBeTrue("the third attach must overflow into a fresh entry");
|
||||
factoryCalls.ShouldBe(1, "the factory must fire to create the overflow entry");
|
||||
third.ShouldNotBeSameAs(first, "the overflow must be a distinct InFlightRequest");
|
||||
third.InterestedParties.Count.ShouldBe(1, "the overflow entry starts with only its triggering party");
|
||||
first.InterestedParties.Count.ShouldBe(2, "the original entry stays capped at maxParties");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipeA.DisposeAsync();
|
||||
await pipeB.DisposeAsync();
|
||||
await pipeC.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRemove_AfterAttach_AllPartiesPresent_InRetrievedEntry()
|
||||
{
|
||||
var pipeA = MakePipe();
|
||||
var pipeB = MakePipe();
|
||||
try
|
||||
{
|
||||
var map = new InFlightByKeyMap();
|
||||
var key = new CoalescingKey(1, 0x03, 100, 1);
|
||||
|
||||
var partyA = new InterestedParty(pipeA, 1);
|
||||
var partyB = new InterestedParty(pipeB, 2);
|
||||
|
||||
map.TryAttachOrCreate(key, partyA, () => MakeRequest(partyA), 32, out _, out _);
|
||||
map.TryAttachOrCreate(key, partyB, () => MakeRequest(partyB), 32, out _, out _);
|
||||
|
||||
bool removed = map.TryRemove(key, out var req);
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
req.InterestedParties.Count.ShouldBe(2, "both attached parties must be present in the removed entry");
|
||||
map.Count.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await pipeA.DisposeAsync();
|
||||
await pipeB.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRemove_OfMissing_ReturnsFalse()
|
||||
{
|
||||
var map = new InFlightByKeyMap();
|
||||
var key = new CoalescingKey(1, 0x03, 100, 1);
|
||||
|
||||
map.TryRemove(key, out _).ShouldBeFalse("removing a never-attached key must report false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_AttachOrCreate_From_Two_Threads_NoLostParties_AndNoDuplicateEntries()
|
||||
{
|
||||
// 16 tasks × 500 ops each, all racing on the same key. The map must keep exactly
|
||||
// one entry per key (unlimited MaxParties → no overflow). Each successful attach
|
||||
// must contribute exactly one party to whatever entry was created/joined.
|
||||
//
|
||||
// Each task reuses a single UpstreamPipe across its ops — the map only stores the
|
||||
// InterestedParty reference; pipe state is irrelevant to the map's invariants.
|
||||
// Spinning up 100 × 1000 = 100,000 loopback sockets exhausts the test machine's
|
||||
// ephemeral port pool; we use one pipe per task instead.
|
||||
const int Tasks = 16;
|
||||
const int OpsPerTask = 500;
|
||||
const int MaxParties = int.MaxValue;
|
||||
|
||||
var map = new InFlightByKeyMap();
|
||||
var key = new CoalescingKey(1, 0x03, 100, 1);
|
||||
var pipes = new List<UpstreamPipe>(Tasks);
|
||||
for (int i = 0; i < Tasks; i++) pipes.Add(MakePipe());
|
||||
|
||||
long attaches = 0;
|
||||
long creates = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var work = new Task[Tasks];
|
||||
var workCt = TestContext.Current.CancellationToken;
|
||||
for (int t = 0; t < Tasks; t++)
|
||||
{
|
||||
var pipe = pipes[t];
|
||||
work[t] = Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < OpsPerTask; i++)
|
||||
{
|
||||
if (workCt.IsCancellationRequested) return;
|
||||
var party = new InterestedParty(pipe, (ushort)i);
|
||||
map.TryAttachOrCreate(
|
||||
key, party,
|
||||
factory: () => MakeRequest(party),
|
||||
maxParties: MaxParties,
|
||||
out _, out bool wasNew);
|
||||
if (wasNew) Interlocked.Increment(ref creates);
|
||||
else Interlocked.Increment(ref attaches);
|
||||
}
|
||||
}, workCt);
|
||||
}
|
||||
await Task.WhenAll(work);
|
||||
|
||||
(creates + attaches).ShouldBe((long)(Tasks * OpsPerTask), "every op must take exactly one branch");
|
||||
creates.ShouldBe(1, "all ops share the same key with unlimited MaxParties — exactly one create");
|
||||
|
||||
// The retained entry must contain every attached party.
|
||||
bool removed = map.TryRemove(key, out var entry);
|
||||
removed.ShouldBeTrue();
|
||||
entry.InterestedParties.Count.ShouldBe(Tasks * OpsPerTask,
|
||||
"the entry's party list must hold every attached party — no lost parties under race");
|
||||
map.Count.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var p in pipes)
|
||||
try { await p.DisposeAsync(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,9 +379,10 @@ public sealed class PlcMultiplexerTests
|
||||
try
|
||||
{
|
||||
// Both clients use the same upstream TxId 0x0007 — the proxy must hand out
|
||||
// distinct proxy TxIds on the backend wire.
|
||||
// distinct proxy TxIds on the backend wire. Phase 10: reads target DIFFERENT
|
||||
// addresses so coalescing does not fuse them into a single backend request.
|
||||
await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 10, 1), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.IO;
|
||||
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 coverage of Phase-10 read coalescing against the pymodbus DL205 simulator.
|
||||
///
|
||||
/// <para><b>pymodbus 3.13.0 simulator quirk.</b> The sim's <c>ServerRequestHandler</c>
|
||||
/// stores a single <c>last_pdu</c> per connection; two MBAP frames arriving in the same
|
||||
/// recv-buffer overwrite each other's TxId. The real DL260 ECOM does not suffer this.
|
||||
/// For Phase-10 E2E we therefore use the simulator only to verify rewriter integration
|
||||
/// and status-page wiring on serialised requests; the coalescing-active-during-overlap
|
||||
/// proof lives in <see cref="ReadCoalescingTests"/> against a stub backend with
|
||||
/// deterministic response delays.</para>
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class ReadCoalescingE2ETests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
public ReadCoalescingE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int p = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return p;
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Concurrent identical reads — coalescing-ratio surfaces in counters ────
|
||||
|
||||
/// <summary>
|
||||
/// Five concurrent FC03 reads of the same BCD-configured register through the proxy.
|
||||
/// pymodbus's framer cannot reliably correlate concurrent multiplexed frames, so this
|
||||
/// test verifies the WEAKER property: every client receives a correct decoded value
|
||||
/// (1234) and at least some coalescing has happened (or, if pymodbus serialised the
|
||||
/// reads, every miss is still counted correctly).
|
||||
/// </summary>
|
||||
[Fact(Timeout = 8_000)]
|
||||
public async Task E2E_FiveConcurrentClients_SameReadHR1072_AllSucceed_AndCounterAccountingBalances()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
||||
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
||||
// Default ReadCoalescing.Enabled = true (set on ResilienceOptions).
|
||||
|
||||
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);
|
||||
|
||||
// Five clients reading sequentially — pymodbus serialisation friendly. With
|
||||
// coalescing-on, identical reads issued back-to-back will mostly serialise on
|
||||
// the wire too (one round-trip completes before the next starts), so this test
|
||||
// does NOT assert hit-count > 0. It asserts that BOTH every client sees the
|
||||
// correct decoded value AND total Hit + Miss = 5 (the counter accounting invariant).
|
||||
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);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. BCD rewriter still works under coalescing fan-out ─────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the rewriter sees a coalesced response correctly: the TxId restoration
|
||||
/// for the second party must not perturb the BCD byte rewrite. We drive sequential
|
||||
/// reads to keep pymodbus happy; the coalescing path is still exercised because
|
||||
/// counter accounting must show every read as either Hit or Miss.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_RewriterStillWorks_ForCoalescedReads()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
||||
config["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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Multiple read passes — same register, same expected decoded value across
|
||||
// all clients. The BCD rewriter must produce 1234 for every party regardless
|
||||
// of which coalescing branch (hit vs miss) the request took.
|
||||
for (int pass = 0; pass < 3; pass++)
|
||||
{
|
||||
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,
|
||||
$"pass {pass} client #{i}: decoded value must survive coalescing");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Different registers → no coalescing → hit count stays at zero ─────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_DifferentRegisters_NotCoalesced_CoalescedHitCount_Zero()
|
||||
{
|
||||
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(300, TestContext.Current.CancellationToken);
|
||||
|
||||
// Five different seeded addresses, sequential reads — none can coalesce.
|
||||
// Selected from DL260/dl205.json's seeded ranges (200..209, 1024, 1040..1042).
|
||||
ushort[] addrs = [200, 201, 202, 203, 204];
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
foreach (ushort a in addrs)
|
||||
_ = master.ReadHoldingRegisters(1, a, 1);
|
||||
}
|
||||
|
||||
// Read the counters via status.json.
|
||||
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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
backend.GetProperty("coalescedHitCount").GetInt64()
|
||||
.ShouldBe(0, "different addresses must never coalesce");
|
||||
backend.GetProperty("coalescedMissCount").GetInt64()
|
||||
.ShouldBe(addrs.Length, "each distinct read must be counted as a Miss");
|
||||
}
|
||||
|
||||
// ── 4. Status page surfaces coalescing counters ──────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_StatusPage_Shows_CoalescingFields()
|
||||
{
|
||||
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(300, 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);
|
||||
_ = master.ReadHoldingRegisters(1, 0, 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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
backend.TryGetProperty("coalescedHitCount", out _)
|
||||
.ShouldBeTrue("status.json must expose backend.coalescedHitCount");
|
||||
backend.TryGetProperty("coalescedMissCount", out _)
|
||||
.ShouldBeTrue("status.json must expose backend.coalescedMissCount");
|
||||
backend.TryGetProperty("coalescedResponseToDeadUpstream", out _)
|
||||
.ShouldBeTrue("status.json must expose backend.coalescedResponseToDeadUpstream");
|
||||
}
|
||||
|
||||
// ── 5. Disable via config → coalescing OFF → every read is a Miss ────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_CoalescingDisabledViaConfig_EveryReadIsAMiss()
|
||||
{
|
||||
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();
|
||||
config["Mbproxy:Resilience:ReadCoalescing:Enabled"] = "false";
|
||||
|
||||
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(300, 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);
|
||||
for (int i = 0; i < 4; i++)
|
||||
_ = master.ReadHoldingRegisters(1, 0, 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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
||||
|
||||
backend.GetProperty("coalescedHitCount").GetInt64()
|
||||
.ShouldBe(0, "coalescing disabled — no hits possible");
|
||||
backend.GetProperty("coalescedMissCount").GetInt64()
|
||||
.ShouldBe(4, "every FC03 read still counts as a Miss when coalescing is disabled");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
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>
|
||||
/// Phase-10 unit tests for read coalescing against a stub backend (real sockets, no
|
||||
/// simulator). The stub gives us deterministic control over backend response timing so
|
||||
/// the "overlapping in-flight" window is large enough for late requests to actually
|
||||
/// coalesce. The pymodbus simulator cannot be used here — its known concurrent-MBAP-frame
|
||||
/// bug (see <see cref="MultiplexerE2ETests"/>) would invalidate the proxy-TxId echo path
|
||||
/// that coalescing relies on.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReadCoalescingTests
|
||||
{
|
||||
// ── Frame builders / readers ─────────────────────────────────────────────────
|
||||
|
||||
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 async Task<byte[]> ReadExactAsync(Socket s, int count, CancellationToken ct)
|
||||
{
|
||||
var buf = new byte[count];
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
int n = await s.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 s, CancellationToken ct)
|
||||
{
|
||||
var header = await ReadExactAsync(s, 7, ct);
|
||||
ushort length = (ushort)((header[4] << 8) | header[5]);
|
||||
int bodyLen = length - 1;
|
||||
var body = bodyLen > 0 ? await ReadExactAsync(s, 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[] BuildFc03(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 static byte[] BuildFc04(ushort txId, ushort start, ushort qty, byte unit = 1)
|
||||
=> [
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unit, 0x04,
|
||||
(byte)(start >> 8), (byte)(start & 0xFF),
|
||||
(byte)(qty >> 8), (byte)(qty & 0xFF),
|
||||
];
|
||||
|
||||
private static byte[] BuildFc06(ushort txId, ushort addr, ushort value, byte unit = 1)
|
||||
=> [
|
||||
(byte)(txId >> 8), (byte)(txId & 0xFF),
|
||||
0x00, 0x00,
|
||||
0x00, 0x06,
|
||||
unit, 0x06,
|
||||
(byte)(addr >> 8), (byte)(addr & 0xFF),
|
||||
(byte)(value >> 8), (byte)(value & 0xFF),
|
||||
];
|
||||
|
||||
private static byte[] BuildFc03Response(ushort txId, byte unit, params ushort[] regs)
|
||||
{
|
||||
int bodyLen = 2 + regs.Length * 2;
|
||||
var frame = new byte[7 + bodyLen];
|
||||
frame[0] = (byte)(txId >> 8);
|
||||
frame[1] = (byte)(txId & 0xFF);
|
||||
frame[2] = 0; frame[3] = 0;
|
||||
ushort len = (ushort)(1 + bodyLen);
|
||||
frame[4] = (byte)(len >> 8);
|
||||
frame[5] = (byte)(len & 0xFF);
|
||||
frame[6] = unit;
|
||||
frame[7] = 0x03;
|
||||
frame[8] = (byte)(regs.Length * 2);
|
||||
for (int i = 0; i < regs.Length; i++)
|
||||
{
|
||||
frame[9 + i * 2] = (byte)(regs[i] >> 8);
|
||||
frame[9 + i * 2 + 1] = (byte)(regs[i] & 0xFF);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] BuildFc06Response(ushort txId, byte unit, ushort addr, ushort value)
|
||||
{
|
||||
var frame = new byte[12];
|
||||
frame[0] = (byte)(txId >> 8);
|
||||
frame[1] = (byte)(txId & 0xFF);
|
||||
frame[2] = 0; frame[3] = 0;
|
||||
frame[4] = 0; frame[5] = 6;
|
||||
frame[6] = unit;
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Holding-the-response stub backend ─────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Stub backend that delays its response by <see cref="ResponseDelayMs"/>. The delay
|
||||
/// gives the test a deterministic in-flight window so a second client's identical
|
||||
/// request actually overlaps the first request's wire-time. Records every proxy TxId
|
||||
/// it sees so the test can count distinct backend round-trips.
|
||||
/// </summary>
|
||||
private sealed class DelayedStubBackend : IAsyncDisposable
|
||||
{
|
||||
public int Port { get; }
|
||||
public int ResponseDelayMs { get; set; } = 200;
|
||||
public ConcurrentQueue<ushort> SeenProxyTxIds { get; } = new();
|
||||
public int RequestCount => SeenProxyTxIds.Count;
|
||||
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _clientTasks = new();
|
||||
|
||||
public DelayedStubBackend(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 t = Task.Run(() => HandleAsync(s));
|
||||
lock (_clientTasks) _clientTasks.Add(t);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
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]);
|
||||
byte unit = req[6];
|
||||
byte fc = req[7];
|
||||
|
||||
SeenProxyTxIds.Enqueue(txId);
|
||||
|
||||
// Schedule the response asynchronously so the next request (from a
|
||||
// second client) can race onto the multiplexer while this one is
|
||||
// still in flight.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(ResponseDelayMs, _cts.Token);
|
||||
byte[] response;
|
||||
if (fc == 0x03 || fc == 0x04)
|
||||
{
|
||||
// Default register value 0x1234 (BCD 1234).
|
||||
response = BuildFc03Response(txId, unit, 0x1234);
|
||||
response[7] = fc; // restore actual FC byte
|
||||
}
|
||||
else if (fc == 0x06)
|
||||
{
|
||||
ushort addr = (ushort)((req[8] << 8) | req[9]);
|
||||
ushort val = (ushort)((req[10] << 8) | req[11]);
|
||||
response = BuildFc06Response(txId, unit, addr, val);
|
||||
}
|
||||
else { return; }
|
||||
await s.SendAsync(response, SocketFlags.None, _cts.Token);
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mux construction / client helpers ────────────────────────────────────
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private static PlcMultiplexer BuildMux(
|
||||
PlcOptions plc,
|
||||
ConnectionOptions connOpts,
|
||||
PerPlcContext ctx,
|
||||
ReadCoalescingOptions coalescing)
|
||||
{
|
||||
return new PlcMultiplexer(
|
||||
plc, connOpts,
|
||||
new BcdPduPipeline(),
|
||||
ctx,
|
||||
NullLogger<PlcMultiplexer>.Instance,
|
||||
backendConnectPipeline: null,
|
||||
coalescingOptions: () => coalescing);
|
||||
}
|
||||
|
||||
private static async Task<(Socket client, UpstreamPipe pipe, TcpListener proxyListener)>
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task TwoClients_SameRequest_OnlyOneBackendRoundTrip()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 300 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 32 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// First client opens the in-flight entry; small gap lets the multiplexer enqueue
|
||||
// before the second arrives. The 300 ms delay then gives the second client
|
||||
// ample window to coalesce onto the first.
|
||||
await c1.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
await Task.Delay(80, TestContext.Current.CancellationToken);
|
||||
await c2.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
|
||||
var r1 = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
var r2 = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001);
|
||||
((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0002);
|
||||
|
||||
backend.RequestCount.ShouldBe(1, "exactly one backend round-trip must service both clients");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBe(1);
|
||||
ctx.Counters.Snapshot().CoalescedMissCount.ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoClients_DifferentRequests_BothHitBackend()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 50 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 32 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Different start addresses → different keys → no coalescing.
|
||||
await c1.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03(0x0002, 200, 1), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
backend.RequestCount.ShouldBe(2, "two distinct keys must produce two backend round-trips");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBe(0);
|
||||
ctx.Counters.Snapshot().CoalescedMissCount.ShouldBe(2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FiveClients_SameRequest_OneBackendRoundTrip_FiveResponses()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 400 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 32 });
|
||||
|
||||
var sockets = new List<Socket>();
|
||||
var pipes = new List<UpstreamPipe>();
|
||||
var lists = new List<TcpListener>();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
|
||||
sockets.Add(c); pipes.Add(p); lists.Add(l);
|
||||
}
|
||||
|
||||
// First client opens; the rest race in during the 400 ms window.
|
||||
await sockets[0].SendAsync(BuildFc03((ushort)1, 100, 1), SocketFlags.None);
|
||||
await Task.Delay(60, TestContext.Current.CancellationToken);
|
||||
for (int i = 1; i < sockets.Count; i++)
|
||||
await sockets[i].SendAsync(BuildFc03((ushort)(i + 1), 100, 1), SocketFlags.None);
|
||||
|
||||
// Read back every client's response.
|
||||
for (int i = 0; i < sockets.Count; i++)
|
||||
{
|
||||
var rsp = await ReadOneFrameAsync(sockets[i], TestContext.Current.CancellationToken);
|
||||
((ushort)((rsp[0] << 8) | rsp[1])).ShouldBe((ushort)(i + 1),
|
||||
$"client #{i} must see its own original TxId restored");
|
||||
}
|
||||
|
||||
backend.RequestCount.ShouldBeLessThanOrEqualTo(2,
|
||||
"at most 2 backend round-trips (one for the leader, one for any racy first-arrival)");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBeGreaterThanOrEqualTo(3,
|
||||
"at least 3 of the 5 clients should have coalesced");
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var s in sockets) s.Dispose();
|
||||
foreach (var p in pipes) await p.DisposeAsync();
|
||||
foreach (var l in lists) l.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FC03_And_FC04_SameAddress_NOT_Coalesced()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 200 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 32 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// FC03 vs FC04 — different Modbus tables, never coalesce.
|
||||
await c1.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc04(0x0002, 100, 1), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
backend.RequestCount.ShouldBe(2, "FC03 and FC04 must never share a backend round-trip");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FC06_Write_NeverCoalesced()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 100 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 32 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
// Two identical FC06 writes — writes must never coalesce (non-idempotent).
|
||||
await c1.SendAsync(BuildFc06(0x0001, 200, 1234), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc06(0x0002, 200, 1234), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
backend.RequestCount.ShouldBe(2, "FC06 writes must always hit the backend separately");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBe(0,
|
||||
"writes are never counted as coalescing hits");
|
||||
ctx.Counters.Snapshot().CoalescedMissCount.ShouldBe(0,
|
||||
"writes are not part of the coalescing accounting");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneClient_DisconnectsMidFlight_OthersStillGetResponse_AndDeadUpstreamCounterIncrements()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 400 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 32 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await c1.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
await Task.Delay(60, TestContext.Current.CancellationToken);
|
||||
await c2.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
await Task.Delay(60, TestContext.Current.CancellationToken);
|
||||
|
||||
// Drop client 1 mid-flight (before the backend response arrives).
|
||||
c1.Dispose();
|
||||
await p1.DisposeAsync();
|
||||
|
||||
// Client 2 must still get its response.
|
||||
var r2 = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0002);
|
||||
|
||||
// Give fan-out a beat to record the dead-upstream skip on the c1 side.
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
|
||||
ctx.Counters.Snapshot().CoalescedResponseToDeadUpstream.ShouldBeGreaterThanOrEqualTo(1,
|
||||
"the disconnected client's fan-out slot must increment the dead-upstream counter");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c2.Dispose();
|
||||
await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AtMaxParties_NextRequest_StartsFreshBackendRoundTrip()
|
||||
{
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 400 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
// MaxParties = 2 forces the third identical request to open a fresh entry.
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = true, MaxParties = 2 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c3, p3, l3) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await c1.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
await c2.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
await c3.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c3, TestContext.Current.CancellationToken);
|
||||
|
||||
backend.RequestCount.ShouldBe(2,
|
||||
"MaxParties=2 caps the first entry at 2; the third request opens its own round-trip");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBe(1, "exactly one party joined the leader");
|
||||
ctx.Counters.Snapshot().CoalescedMissCount.ShouldBe(2,
|
||||
"the leader and the overflow are both misses");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose(); c3.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync(); await p3.DisposeAsync();
|
||||
l1.Stop(); l2.Stop(); l3.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CoalescingDisabled_TwoIdenticalReads_BothHitBackend()
|
||||
{
|
||||
// Sanity: with Enabled=false the multiplexer takes the Phase-9 path for every
|
||||
// FC03/FC04 request. Both identical reads must produce a backend round-trip and
|
||||
// every request counts as a Miss (Hit + Miss = total FC03/FC04 invariant).
|
||||
int backendPort = PickFreePort();
|
||||
await using var backend = new DelayedStubBackend(backendPort) { ResponseDelayMs = 50 };
|
||||
|
||||
var ctx = MakeContext("PLC1");
|
||||
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
|
||||
await using var mux = BuildMux(plc, new ConnectionOptions(),
|
||||
ctx, new ReadCoalescingOptions { Enabled = false, MaxParties = 32 });
|
||||
|
||||
var (c1, p1, l1) = await ConnectClientAsync(mux, plc.Name);
|
||||
var (c2, p2, l2) = await ConnectClientAsync(mux, plc.Name);
|
||||
try
|
||||
{
|
||||
await c1.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
|
||||
|
||||
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
|
||||
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
|
||||
|
||||
backend.RequestCount.ShouldBe(2, "coalescing disabled: each identical read must hit the backend");
|
||||
ctx.Counters.Snapshot().CoalescedHitCount.ShouldBe(0);
|
||||
ctx.Counters.Snapshot().CoalescedMissCount.ShouldBe(2, "every FC03 request still counts as a Miss");
|
||||
}
|
||||
finally
|
||||
{
|
||||
c1.Dispose(); c2.Dispose();
|
||||
await p1.DisposeAsync(); await p2.DisposeAsync();
|
||||
l1.Stop(); l2.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user