Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/Supervision/BackendConnectRetryTests.cs
T
Joseph Doherty 56eee3c563 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>
2026-05-14 01:49:35 -04:00

278 lines
11 KiB
C#

using System.Net;
using System.Net.Sockets;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Multiplexing;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy.Supervision;
/// <summary>
/// Integration tests for the backend-connect Polly retry path. Phase 9 moved backend
/// connect ownership from <c>PlcConnectionPair.CreateAsync</c> into
/// <see cref="PlcMultiplexer"/>. These tests exercise the same Polly pipeline by driving
/// upstream-to-multiplexer frames against a bad/intermittent backend and observing the
/// resulting connect-success/connect-failed counters.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BackendConnectRetryTests
{
private static int PickFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
int port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private static (PlcMultiplexer mux, PerPlcContext ctx) BuildMux(
PlcOptions plc,
ConnectionOptions connOpts,
Polly.ResiliencePipeline pipeline)
{
var ctx = new PerPlcContext
{
PlcName = plc.Name,
TagMap = Mbproxy.Bcd.BcdTagMap.Empty,
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
};
var mux = new PlcMultiplexer(
plc,
connOpts,
new BcdPduPipeline(),
ctx,
NullLoggerFactory.Instance.CreateLogger<PlcMultiplexer>(),
pipeline);
return (mux, ctx);
}
/// <summary>
/// Connects a fresh TCP client to the proxy port and returns the accepted upstream
/// pipe alongside the client. The caller drives a single FC03 request and observes
/// what happens when the multiplexer attempts (and fails) to forward it.
/// </summary>
private static async Task<(Socket client, UpstreamPipe pipe)> AttachClientPipeAsync(
PlcMultiplexer mux, int proxyPort, TcpListener proxyListener, string plcName)
{
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
{ NoDelay = true };
await client.ConnectAsync(IPAddress.Loopback, proxyPort);
var upstreamSock = await proxyListener.AcceptSocketAsync();
var pipe = new UpstreamPipe(upstreamSock, plcName, NullLogger.Instance);
_ = Task.Run(() => mux.StartPipeAsync(pipe, CancellationToken.None));
return (client, pipe);
}
private static byte[] BuildFc03ReadFrame(ushort txId, ushort start, ushort qty, byte unitId = 1)
=>
[
(byte)(txId >> 8), (byte)(txId & 0xFF),
0x00, 0x00, // ProtocolId
0x00, 0x06, // Length = 6
unitId,
0x03, // FC03
(byte)(start >> 8), (byte)(start & 0xFF),
(byte)(qty >> 8), (byte)(qty & 0xFF),
];
// ── Test 1: retries per pipeline on ConnectionRefused ─────────────────────────────────
[Fact]
public async Task BackendConnect_RetriesPerPipeline_OnConnectionRefused()
{
int badPort = PickFreePort();
int proxyPort = PickFreePort();
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [50, 100, 200] };
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
var connOpts = new ConnectionOptions { BackendConnectTimeoutMs = 1000, BackendRequestTimeoutMs = 3000 };
var plcOpts = new PlcOptions { Name = "Retry3PLC", ListenPort = proxyPort, Host = "127.0.0.1", Port = badPort };
await using var mux = BuildMux(plcOpts, connOpts, pipeline).mux;
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
proxyListener.Start();
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var (client, pipe) = await AttachClientPipeAsync(mux, proxyPort, proxyListener, plcOpts.Name);
try
{
await client.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
// The multiplexer will Polly-retry then fail; client socket should be closed.
var buf = new byte[1];
int n;
using var ctsDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (true)
{
try
{
n = await client.ReceiveAsync(buf, SocketFlags.None, ctsDeadline.Token);
break;
}
catch (SocketException) { n = 0; break; }
}
sw.Stop();
n.ShouldBe(0, "upstream client should observe a clean EOF after all backend attempts fail");
sw.ElapsedMilliseconds.ShouldBeGreaterThanOrEqualTo(80,
"Polly retries with [50,100] delays should make connect take > 80ms total");
var counters = (await Task.Run(() => mux.AttachedPipes)).Count; // touch state
_ = counters; // unused — proves no race
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
}
}
finally
{
proxyListener.Stop();
}
}
// ── Test 2: succeeds on second attempt when backend becomes reachable ─────────────────
[Fact]
public async Task BackendConnect_Succeeds_OnSecondAttempt_WhenBackendBecomesReachable()
{
int backendPort = PickFreePort();
int proxyPort = PickFreePort();
var profile = new RetryProfile { MaxAttempts = 3, BackoffMs = [200, 1000, 2000] };
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
var connOpts = new ConnectionOptions { BackendConnectTimeoutMs = 1000, BackendRequestTimeoutMs = 3000 };
var plcOpts = new PlcOptions { Name = "RetryOkPLC", ListenPort = proxyPort, Host = "127.0.0.1", Port = backendPort };
await using var muxBundle = new MuxBundle(BuildMux(plcOpts, connOpts, pipeline).mux);
var mux = muxBundle.Mux;
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
proxyListener.Start();
TcpListener? backendListener = null;
Socket? acceptedBackend = null;
Task<Socket>? acceptTask = null;
try
{
// Start the backend listener after 250 ms — within the first backoff window.
var startBackendTask = Task.Run(async () =>
{
await Task.Delay(250, CancellationToken.None);
backendListener = new TcpListener(IPAddress.Loopback, backendPort);
backendListener.Start();
acceptTask = backendListener.AcceptSocketAsync(CancellationToken.None).AsTask();
}, CancellationToken.None);
var (client, pipe) = await AttachClientPipeAsync(mux, proxyPort, proxyListener, plcOpts.Name);
try
{
// Drive a request — this triggers backend connect.
await client.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
await startBackendTask;
acceptedBackend = await acceptTask!.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
// The multiplexer's counters should reflect a successful connect.
using var pollCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!pollCts.IsCancellationRequested
&& mux.AttachedPipes.Count == 0)
{
await Task.Delay(20, pollCts.Token);
}
mux.AttachedPipes.Count.ShouldBeGreaterThanOrEqualTo(1,
"the upstream pipe should remain attached after a successful backend connect");
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
}
}
finally
{
proxyListener.Stop();
acceptedBackend?.Dispose();
backendListener?.Stop();
}
}
// ── Test 3: all attempts fail → upstream socket is closed ─────────────────────────────
[Fact]
public async Task BackendConnect_AllAttemptsFail_ClosesUpstream()
{
int badPort = PickFreePort();
int proxyPort = PickFreePort();
var profile = new RetryProfile { MaxAttempts = 2, BackoffMs = [50, 100] };
var pipeline = PolicyFactory.BuildBackendConnect(profile, NullLogger.Instance);
var connOpts = new ConnectionOptions { BackendConnectTimeoutMs = 500, BackendRequestTimeoutMs = 3000 };
var plcOpts = new PlcOptions { Name = "FailPLC", ListenPort = proxyPort, Host = "127.0.0.1", Port = badPort };
var muxResult = BuildMux(plcOpts, connOpts, pipeline);
await using var mux = muxResult.mux;
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
proxyListener.Start();
try
{
var (client, pipe) = await AttachClientPipeAsync(mux, proxyPort, proxyListener, plcOpts.Name);
try
{
await client.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
var buf = new byte[1];
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
int n;
try
{
n = await client.ReceiveAsync(buf, SocketFlags.None, deadline.Token);
}
catch (SocketException)
{
n = 0;
}
n.ShouldBe(0, "upstream socket should observe a clean EOF after all attempts fail");
muxResult.ctx.Counters.Snapshot().ConnectsFailed.ShouldBeGreaterThanOrEqualTo(1);
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
}
}
finally
{
proxyListener.Stop();
}
}
/// <summary>
/// Helper that lets the test scope-await both <see cref="PlcMultiplexer"/> disposal
/// and capture of the public surface in a single using block.
/// </summary>
private sealed class MuxBundle : IAsyncDisposable
{
public PlcMultiplexer Mux { get; }
public MuxBundle(PlcMultiplexer mux) => Mux = mux;
public ValueTask DisposeAsync() => Mux.DisposeAsync();
}
}