mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Mbproxy;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proxy forwarding tests.
|
||||
/// Each test:
|
||||
/// 1. Starts the proxy host in-process, configured with one PLC pointing at the simulator.
|
||||
/// 2. Connects NModbus to the proxy's listen port.
|
||||
/// 3. Asserts the proxy forwards bytes transparently (NoopPduPipeline — no BCD rewriting).
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class ProxyForwardingTests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
|
||||
public ProxyForwardingTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
|
||||
{
|
||||
_sim = sim;
|
||||
}
|
||||
|
||||
// ── 1. FC03 read HR0 — expect 0xCAFE ───────────────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC03_HR0_Returns_SimulatorRawValue_0xCAFE()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 0, numberOfPoints: 1);
|
||||
|
||||
Assert.Equal(0xCAFE, regs[0]);
|
||||
}
|
||||
|
||||
// ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ──────────────────────
|
||||
// Replaced Phase 03 placeholder: Forward_FC03_HR1072_Returns_RawBCD_0x1234
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC03_HR1072_Returns_Decoded_1234()
|
||||
{
|
||||
// Phase 04: BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured,
|
||||
// the proxy decodes the raw 0x1234 nibbles and the client receives binary 1234.
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
// Configure address 1072 as a 16-bit BCD tag.
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var host = BuildBcdProxyHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
|
||||
|
||||
// BCD decoded: 0x1234 → binary 1234.
|
||||
Assert.Equal(1234, regs[0]);
|
||||
}
|
||||
|
||||
// ── 2b. FC03 read HR1072 — without BCD configured → raw 0x1234 ─────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC03_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234()
|
||||
{
|
||||
// When no BCD tag is configured at address 1072, the proxy passes bytes through
|
||||
// unmodified. Client receives raw BCD nibbles 0x1234 (= 4660 decimal).
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
|
||||
|
||||
// No BCD tag configured: raw BCD nibbles pass through.
|
||||
Assert.Equal(0x1234, regs[0]);
|
||||
}
|
||||
|
||||
// ── 3. FC06 write single register then read back ────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC06_WriteHR200_ThenReadBack_RoundTrips()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
const ushort writeValue = 0xABCD;
|
||||
master.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: writeValue);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1);
|
||||
Assert.Equal(writeValue, regs[0]);
|
||||
}
|
||||
|
||||
// ── 4. FC16 write multiple registers then read back ──────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task Forward_FC16_WriteMultipleHR201_203_ThenReadBack_RoundTrips()
|
||||
{
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
|
||||
ushort[] writeValues = [0x0010, 0x0020, 0x0030];
|
||||
master.WriteMultipleRegisters(slaveAddress: 1, startAddress: 201, data: writeValues);
|
||||
|
||||
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 201, numberOfPoints: 3);
|
||||
Assert.Equal(writeValues, regs);
|
||||
}
|
||||
|
||||
// ── 5. MBAP TxId preserved end-to-end ────────────────────────────────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task MbapTxId_IsPreservedEndToEnd()
|
||||
{
|
||||
// Issue 20 back-to-back FC03 reads with manually-incrementing TxIds (via raw sockets)
|
||||
// and verify every response carries the matching TxId.
|
||||
// This verifies no mid-stream frame split causes a parse failure under stress.
|
||||
if (_sim.SkipReason is not null)
|
||||
Assert.Skip(_sim.SkipReason);
|
||||
|
||||
var (proxyPort, host, cts) = await StartProxyAsync();
|
||||
await using var _ = new AsyncHostDispose(host, cts);
|
||||
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket.NoDelay = true;
|
||||
await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
|
||||
const int count = 20;
|
||||
byte[] reqBuf = new byte[12]; // FC03 request frame
|
||||
byte[] rspBuf = new byte[260];
|
||||
|
||||
for (ushort txId = 1; txId <= count; txId++)
|
||||
{
|
||||
// Build FC03 request: read 1 register at address 0.
|
||||
// [TxId(2), ProtocolId(2)=0, Length(2)=6, UnitId=1, FC=03, Start(2)=0, Qty(2)=1]
|
||||
reqBuf[0] = (byte)(txId >> 8);
|
||||
reqBuf[1] = (byte)(txId & 0xFF);
|
||||
reqBuf[2] = 0x00; // ProtocolId high
|
||||
reqBuf[3] = 0x00; // ProtocolId low
|
||||
reqBuf[4] = 0x00; // Length high
|
||||
reqBuf[5] = 0x06; // Length low (6 bytes: UnitId + FC + 4 PDU bytes)
|
||||
reqBuf[6] = 0x01; // UnitId
|
||||
reqBuf[7] = 0x03; // FC03
|
||||
reqBuf[8] = 0x00; // Start addr high
|
||||
reqBuf[9] = 0x00; // Start addr low
|
||||
reqBuf[10] = 0x00; // Qty high
|
||||
reqBuf[11] = 0x01; // Qty low
|
||||
|
||||
await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
|
||||
// Read response header (7 bytes), then body.
|
||||
int read = 0;
|
||||
while (read < 7)
|
||||
read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
|
||||
// Parse response TxId.
|
||||
ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]);
|
||||
ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]);
|
||||
|
||||
Assert.Equal(txId, rspTxId);
|
||||
|
||||
// Drain the response body.
|
||||
int bodyLen = rspLength - 1; // length covers UnitId + PDU; we already read UnitId
|
||||
if (bodyLen > 0)
|
||||
{
|
||||
int bodyRead = 0;
|
||||
while (bodyRead < bodyLen)
|
||||
bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead), SocketFlags.None, TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Backend connect failure — upstream socket closes cleanly ───────────────────────
|
||||
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task BackendConnectFailure_ClosesUpstreamCleanly()
|
||||
{
|
||||
// Point the proxy at port 1 on loopback — guaranteed unreachable.
|
||||
// After Phase 9 the multiplexer lazily connects to the backend on the first
|
||||
// upstream PDU, so we have to actually send a request before the proxy attempts
|
||||
// the (failing) backend connect that closes the upstream.
|
||||
const int badBackendPort = 1;
|
||||
const int backendTimeoutMs = 500; // short timeout for test speed
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "BadPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = "127.0.0.1",
|
||||
[$"Mbproxy:Plcs:0:Port"] = badBackendPort.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = backendTimeoutMs.ToString(),
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
var host = BuildProxyHost(config);
|
||||
await host.StartAsync(cts.Token);
|
||||
|
||||
// Give the proxy a moment to bind.
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
|
||||
// Send a Modbus request so the multiplexer attempts the backend connect.
|
||||
byte[] req =
|
||||
[
|
||||
0x00, 0x01, // TxId
|
||||
0x00, 0x00, // ProtocolId
|
||||
0x00, 0x06, // Length
|
||||
0x01, // UnitId
|
||||
0x03, // FC03
|
||||
0x00, 0x00, // Start
|
||||
0x00, 0x01, // Qty
|
||||
];
|
||||
await client.GetStream().WriteAsync(req, TestContext.Current.CancellationToken);
|
||||
|
||||
// Wait up to BackendConnectTimeoutMs + 600ms for the upstream socket to close.
|
||||
// Polly default retry adds extra time, so we account for it in the deadline.
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(backendTimeoutMs + 1500);
|
||||
bool closed = false;
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
// A 0-byte receive returns 0 when the remote end closed the socket.
|
||||
var buf = new byte[1];
|
||||
int n = await client.GetStream()
|
||||
.ReadAsync(buf.AsMemory(), TestContext.Current.CancellationToken);
|
||||
if (n == 0) { closed = true; break; }
|
||||
}
|
||||
catch
|
||||
{
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
await host.StopAsync(cts.Token);
|
||||
|
||||
Assert.True(closed, "Upstream socket should have been closed by the proxy after backend connect failure.");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartProxyAsync()
|
||||
{
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "8080",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var host = BuildProxyHost(config);
|
||||
await host.StartAsync(startCts.Token);
|
||||
|
||||
// Give the proxy time to bind.
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
|
||||
var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
return (proxyPort, host, runCts);
|
||||
}
|
||||
|
||||
private static IHost BuildProxyHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
// Suppress verbose logging in tests.
|
||||
builder.Services.AddSerilog(
|
||||
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
// Tests in ProxyForwardingTests use NoopPduPipeline to verify raw passthrough
|
||||
// (baseline behaviour independent of BCD configuration).
|
||||
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
||||
builder.Services.AddHostedService<ProxyWorker>();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static IHost BuildBcdProxyHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddSerilog(
|
||||
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
// BCD rewriter pipeline — used by the Phase 04 tests in this file.
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
builder.Services.AddHostedService<ProxyWorker>();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the host and CTS when the test finishes.</summary>
|
||||
private sealed class AsyncHostDispose : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
public AsyncHostDispose(IHost host, CancellationTokenSource cts)
|
||||
{
|
||||
_host = host;
|
||||
_cts = cts;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ }
|
||||
_host.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user