56eee3c563
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
301 lines
11 KiB
C#
301 lines
11 KiB
C#
using System.Net;
|
|
using Mbproxy.Admin;
|
|
using Mbproxy.Options;
|
|
using Mbproxy.Proxy;
|
|
using Mbproxy.Proxy.Supervision;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Options;
|
|
using Serilog;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Admin;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="StatusSnapshotBuilder"/>.
|
|
/// All tests use a real in-process host with <see cref="NoopPduPipeline"/> and
|
|
/// in-memory configuration. No network I/O is required.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class StatusSnapshotBuilderTests
|
|
{
|
|
// ── 1. No PLCs configured → empty PLC list ────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Build_NoPlcsConfigured_ReturnsEmptyPlcList()
|
|
{
|
|
var (host, builder) = await BuildAsync([]);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
|
|
var result = builder.Build();
|
|
|
|
result.Plcs.ShouldBeEmpty();
|
|
result.Listeners.Configured.ShouldBe(0);
|
|
result.Listeners.Bound.ShouldBe(0);
|
|
}
|
|
|
|
// ── 2. One PLC bound → state is "bound" ───────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Build_OnePlcBound_PopulatesListenerState_Bound()
|
|
{
|
|
int port = PickFreePort();
|
|
var (host, builder) = await BuildAsync([("PLC-A", port)]);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
|
|
// Wait for the listener to bind.
|
|
await WaitForAsync(
|
|
() => CanConnect(port),
|
|
TimeSpan.FromSeconds(5),
|
|
"PLC-A listener should bind");
|
|
|
|
var result = builder.Build();
|
|
|
|
var plc = result.Plcs.ShouldHaveSingleItem();
|
|
plc.Name.ShouldBe("PLC-A");
|
|
plc.Listener.State.ShouldBe("bound");
|
|
plc.Listener.LastBindError.ShouldBeNull();
|
|
}
|
|
|
|
// ── 3. PLC recovering → state + last error + attempts ────────────────────
|
|
|
|
[Fact]
|
|
public async Task Build_PlcRecovering_PopulatesLastBindError_AndAttempts()
|
|
{
|
|
// Bind the occupier on ANY so the proxy (also ANY) cannot rebind the same port.
|
|
var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0);
|
|
occupier.Server.SetSocketOption(
|
|
System.Net.Sockets.SocketOptionLevel.Socket,
|
|
System.Net.Sockets.SocketOptionName.ExclusiveAddressUse,
|
|
true);
|
|
occupier.Start();
|
|
int port = ((IPEndPoint)occupier.LocalEndpoint).Port;
|
|
|
|
try
|
|
{
|
|
var (host, builder) = await BuildAsync([("PLC-A", port)], startupWaitMs: 500);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
|
|
// Give the supervisor time to attempt and fail (it enters Recovering state).
|
|
await Task.Delay(300, TestContext.Current.CancellationToken);
|
|
|
|
var result = builder.Build();
|
|
|
|
var plc = result.Plcs.ShouldHaveSingleItem();
|
|
plc.Listener.State.ShouldBe("recovering");
|
|
}
|
|
finally
|
|
{
|
|
occupier.Stop();
|
|
}
|
|
}
|
|
|
|
// ── 4. Aggregate bound/configured ────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Build_AggregatesListenersBoundAndConfigured()
|
|
{
|
|
int portA = PickFreePort();
|
|
|
|
// Occupy portB on ANY with exclusive address use so the proxy cannot rebind it.
|
|
var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0);
|
|
occupier.Server.SetSocketOption(
|
|
System.Net.Sockets.SocketOptionLevel.Socket,
|
|
System.Net.Sockets.SocketOptionName.ExclusiveAddressUse,
|
|
true);
|
|
occupier.Start();
|
|
int portB = ((IPEndPoint)occupier.LocalEndpoint).Port;
|
|
|
|
try
|
|
{
|
|
var (host, builder) = await BuildAsync([("PLC-A", portA), ("PLC-B", portB)],
|
|
startupWaitMs: 400);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
|
|
await WaitForAsync(
|
|
() => CanConnect(portA),
|
|
TimeSpan.FromSeconds(5),
|
|
"PLC-A should bind");
|
|
|
|
// Give portB's supervisor time to make its first (failing) attempt.
|
|
await Task.Delay(200, TestContext.Current.CancellationToken);
|
|
|
|
var result = builder.Build();
|
|
|
|
result.Listeners.Configured.ShouldBe(2);
|
|
result.Listeners.Bound.ShouldBe(1); // only PLC-A is bound
|
|
}
|
|
finally
|
|
{
|
|
occupier.Stop();
|
|
}
|
|
}
|
|
|
|
// ── 5. Per-client snapshot populated after connection ────────────────────
|
|
|
|
[Fact]
|
|
public async Task Build_PerClientSnapshot_Includes_RemoteAndConnectedAt_AndPduCount()
|
|
{
|
|
int proxyPort = PickFreePort();
|
|
|
|
// Start a "fake backend" listener so the multiplexer's backend-connect succeeds.
|
|
var fakeBackend = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
|
fakeBackend.Start();
|
|
int backendPort = ((IPEndPoint)fakeBackend.LocalEndpoint).Port;
|
|
|
|
// Track accepted sockets so we can hold them open while the test runs.
|
|
var acceptedSockets = new System.Collections.Generic.List<System.Net.Sockets.Socket>();
|
|
|
|
// Accept connections in the background and keep them open.
|
|
var backendAcceptTask = Task.Run(async () =>
|
|
{
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
var accepted = await fakeBackend.AcceptSocketAsync(CancellationToken.None);
|
|
lock (acceptedSockets) acceptedSockets.Add(accepted);
|
|
}
|
|
catch { break; }
|
|
}
|
|
}, CancellationToken.None);
|
|
|
|
try
|
|
{
|
|
var (host, builder) = await BuildAsync(
|
|
[("PLC-A", proxyPort)],
|
|
backendPort: backendPort);
|
|
await using var hostDispose = new AsyncHostDispose(host);
|
|
|
|
await WaitForAsync(
|
|
() => CanConnect(proxyPort),
|
|
TimeSpan.FromSeconds(5),
|
|
"PLC-A should bind");
|
|
|
|
// Connect a TCP client to the proxy's listen port.
|
|
using var client = new System.Net.Sockets.TcpClient();
|
|
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
|
|
|
// Give the listener a moment to register the pair.
|
|
await Task.Delay(200, TestContext.Current.CancellationToken);
|
|
|
|
var result = builder.Build();
|
|
var plc = result.Plcs.ShouldHaveSingleItem();
|
|
plc.Clients.Connected.ShouldBe(1);
|
|
var clientSnap = plc.Clients.RemoteEndpoints.ShouldHaveSingleItem();
|
|
clientSnap.Remote.ShouldNotBeNullOrEmpty();
|
|
// ConnectedAtUtc should be recent (within 10 s).
|
|
(DateTimeOffset.UtcNow - clientSnap.ConnectedAtUtc).TotalSeconds.ShouldBeLessThan(10);
|
|
}
|
|
finally
|
|
{
|
|
lock (acceptedSockets)
|
|
foreach (var s in acceptedSockets) try { s.Dispose(); } catch { }
|
|
fakeBackend.Stop();
|
|
try { await backendAcceptTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None); } catch { }
|
|
}
|
|
}
|
|
|
|
// ── 6. Service fields: uptime, version, last-reload ──────────────────────
|
|
|
|
[Fact]
|
|
public async Task Build_ServiceFields_IncludeUptime_Version_AndLastReload()
|
|
{
|
|
var (host, builder) = await BuildAsync([]);
|
|
await using var _ = new AsyncHostDispose(host);
|
|
|
|
var counters = host.Services.GetRequiredService<ServiceCounters>();
|
|
var now = DateTimeOffset.UtcNow;
|
|
counters.RecordReloadApplied(now);
|
|
|
|
var result = builder.Build();
|
|
|
|
result.Service.UptimeSeconds.ShouldBeGreaterThanOrEqualTo(0);
|
|
result.Service.Version.ShouldNotBeNullOrEmpty();
|
|
result.Service.ConfigLastReloadUtc.ShouldNotBeNull();
|
|
result.Service.ConfigReloadCount.ShouldBe(1);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
|
|
(string name, int port)[] plcs,
|
|
int startupWaitMs = 200,
|
|
int backendPort = 502)
|
|
{
|
|
var config = new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
|
|
};
|
|
|
|
for (int i = 0; i < plcs.Length; i++)
|
|
{
|
|
config[$"Mbproxy:Plcs:{i}:Name"] = plcs[i].name;
|
|
config[$"Mbproxy:Plcs:{i}:ListenPort"] = plcs[i].port.ToString();
|
|
config[$"Mbproxy:Plcs:{i}:Host"] = "127.0.0.1";
|
|
config[$"Mbproxy:Plcs:{i}:Port"] = backendPort.ToString();
|
|
}
|
|
|
|
var hostBuilder = Host.CreateApplicationBuilder();
|
|
hostBuilder.Configuration.AddInMemoryCollection(config);
|
|
hostBuilder.Services.AddSerilog(
|
|
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
|
dispose: false);
|
|
hostBuilder.AddMbproxyOptions();
|
|
hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
|
|
|
// Register ProxyWorker as singleton so StatusSnapshotBuilder can resolve it by type.
|
|
hostBuilder.Services.AddSingleton<ProxyWorker>();
|
|
hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
|
|
|
// Admin support singletons (no AdminEndpointHost — keep unit tests lean).
|
|
hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
|
|
hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();
|
|
|
|
var host = hostBuilder.Build();
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
|
await host.StartAsync(startCts.Token);
|
|
await Task.Delay(startupWaitMs, TestContext.Current.CancellationToken);
|
|
|
|
var snapshotBuilder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
|
|
return (host, snapshotBuilder);
|
|
}
|
|
|
|
private static int PickFreePort()
|
|
{
|
|
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
|
l.Start();
|
|
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
|
l.Stop();
|
|
return port;
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout, string msg)
|
|
{
|
|
using var cts = new CancellationTokenSource(timeout);
|
|
while (!predicate() && !cts.IsCancellationRequested)
|
|
await Task.Delay(50, cts.Token).ConfigureAwait(false);
|
|
predicate().ShouldBeTrue(msg);
|
|
}
|
|
|
|
private static bool CanConnect(int port)
|
|
{
|
|
try { using var c = new System.Net.Sockets.TcpClient(); c.Connect("127.0.0.1", port); return true; }
|
|
catch { return false; }
|
|
}
|
|
|
|
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(5));
|
|
try { await _host.StopAsync(cts.Token); } catch { }
|
|
_host.Dispose();
|
|
}
|
|
}
|
|
}
|