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:
Joseph Doherty
2026-05-14 01:49:35 -04:00
parent 2e937228a0
commit 56eee3c563
105 changed files with 18430 additions and 0 deletions
@@ -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);
}
}