using System.Net;
using System.Net.Sockets;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Polly;
using Xunit;
namespace Mbproxy.Tests.Proxy.Supervision;
///
/// End-to-end supervisor tests that run the proxy against the DL205 simulator.
/// These tests verify supervisor-level behaviour (recovery, counters) with a real
/// Modbus backend rather than a bare socket.
///
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
public sealed class SupervisorE2ETests
{
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
public SupervisorE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
{
_sim = sim;
}
// ── Helpers ───────────────────────────────────────────────────────────────────────────
private static int PickFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
int port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
private PlcListenerSupervisor BuildSimSupervisor(
int listenPort,
RecoveryProfile? recoveryProfile = null)
{
var profile = recoveryProfile ?? new RecoveryProfile
{
InitialBackoffMs = [200, 200],
SteadyStateMs = 200,
};
ILoggerFactory loggerFactory = NullLoggerFactory.Instance;
var plcOpts = new PlcOptions
{
Name = "SimPLC",
ListenPort = listenPort,
Host = _sim.Host,
Port = _sim.Port,
};
var connOpts = new ConnectionOptions
{
BackendConnectTimeoutMs = 3000,
BackendRequestTimeoutMs = 3000,
};
var recoveryPipeline = PolicyFactory.BuildListenerRecovery(profile, NullLogger.Instance);
var backendPipeline = PolicyFactory.BuildBackendConnect(
new RetryProfile { MaxAttempts = 2, BackoffMs = [100, 500] },
NullLogger.Instance);
return new PlcListenerSupervisor(
plc: plcOpts,
connectionOptions: connOpts,
pipeline: new NoopPduPipeline(),
listenerLogger: loggerFactory.CreateLogger(),
multiplexerLogger: loggerFactory.CreateLogger(),
pipeLogger: loggerFactory.CreateLogger("Mbproxy.Proxy.UpstreamPipe.Test"),
perPlcContext: null,
recoveryPipeline: recoveryPipeline,
logger: loggerFactory.CreateLogger(),
backendConnectPipeline: backendPipeline);
}
// ── E2E 1: Recovery when blocking listener releases port ──────────────────────────────
[Fact(Timeout = 5_000)]
public async Task E2E_Recovery_When_BlockingListenerReleasesPort()
{
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
int listenPort = PickFreePort();
// Block the port before starting the supervisor.
var blocker = new TcpListener(IPAddress.Any, listenPort);
blocker.Start();
await using var supervisor = BuildSimSupervisor(listenPort);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await supervisor.StartAsync(cts.Token);
// Wait for first bind attempt to fail.
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
Assert.Equal(SupervisorState.Recovering, supervisor.Snapshot().State);
// Release the port.
blocker.Stop();
// Poll for up to 3 s for the supervisor to bind.
using var recoveryCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (!recoveryCts.IsCancellationRequested)
{
if (supervisor.Snapshot().State == SupervisorState.Bound)
break;
await Task.Delay(50, TestContext.Current.CancellationToken);
}
Assert.Equal(SupervisorState.Bound, supervisor.Snapshot().State);
// Verify the proxy actually serves traffic by connecting to it.
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", listenPort, cts.Token);
// Send a minimal FC03 request (read 1 register at address 0).
var req = new byte[]
{
0x00, 0x01, // TxId
0x00, 0x00, // ProtocolId
0x00, 0x06, // Length (6)
0x01, // UnitId
0x03, // FC03
0x00, 0x00, // Start address 0
0x00, 0x01, // Qty 1
};
await client.GetStream().WriteAsync(req, cts.Token);
// Read at least 9 bytes (7 header + 2 data minimum for FC03 with 1 register).
var rsp = new byte[260];
int read = 0;
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (read < 9 && !readCts.IsCancellationRequested)
read += await client.GetStream().ReadAsync(rsp.AsMemory(read), readCts.Token);
// Verify we got a response with matching TxId.
Assert.True(read >= 9, $"Expected ≥ 9 bytes, got {read}");
Assert.Equal(0x00, rsp[0]); // TxId high
Assert.Equal(0x01, rsp[1]); // TxId low
await supervisor.StopAsync(cts.Token);
}
// ── E2E 2: RecoveryAttempts counter increments and is visible on Snapshot ─────────────
[Fact(Timeout = 5_000)]
public async Task E2E_RecoveryAttempts_CounterIncrements_Visible_OnSnapshot()
{
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
int listenPort = PickFreePort();
// Block the port so the supervisor enters recovery.
var blocker = new TcpListener(IPAddress.Any, listenPort);
blocker.Start();
// Use short delays to get multiple recovery attempts quickly.
var profile = new RecoveryProfile
{
InitialBackoffMs = [100, 100, 100],
SteadyStateMs = 100,
};
await using var supervisor = BuildSimSupervisor(listenPort, profile);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
await supervisor.StartAsync(cts.Token);
await supervisor.WaitForInitialBindAttemptAsync(cts.Token);
// Wait for multiple recovery attempts to accumulate.
await Task.Delay(600, TestContext.Current.CancellationToken); // ~6 × 100 ms attempts
var snap = supervisor.Snapshot();
Assert.Equal(SupervisorState.Recovering, snap.State);
Assert.True(snap.RecoveryAttempts >= 2,
$"Expected ≥ 2 recovery attempts after 600ms with 100ms backoff; got {snap.RecoveryAttempts}");
Assert.NotNull(snap.LastBindError);
// Release the port and verify recovery.
blocker.Stop();
using var recoveryCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
while (!recoveryCts.IsCancellationRequested)
{
if (supervisor.Snapshot().State == SupervisorState.Bound)
break;
await Task.Delay(50, TestContext.Current.CancellationToken);
}
Assert.Equal(SupervisorState.Bound, supervisor.Snapshot().State);
// RecoveryAttempts must still be the accumulated value (not reset to 0).
var afterSnap = supervisor.Snapshot();
Assert.True(afterSnap.RecoveryAttempts >= snap.RecoveryAttempts,
$"RecoveryAttempts should accumulate; was {snap.RecoveryAttempts}, now {afterSnap.RecoveryAttempts}");
// LastBindError should be cleared after a successful bind.
Assert.Null(afterSnap.LastBindError);
await supervisor.StopAsync(cts.Token);
}
}