1a2856526a
Comments described the *history* of how the code arrived (phase numbers, wave IDs, review IDs, dated TODOs) instead of what it does today. That scaffolding rotted as the codebase evolved. Cleaned 60 source files + .gitignore; behaviour unchanged (387/387 tests still pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
342 lines
16 KiB
C#
342 lines
16 KiB
C#
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.Cache;
|
|
|
|
/// <summary>
|
|
/// End-to-end coverage of the response cache against the pymodbus DL205 simulator.
|
|
///
|
|
/// <para><b>pymodbus 3.13 simulator quirk.</b> These tests serialise reads in the
|
|
/// simulator-backed cases. The cache's behavioural guarantee (a TTL-bounded cache hit
|
|
/// returns the cached value without backend traffic) is independent of the simulator's
|
|
/// known concurrent-MBAP-frame bug — sequential reads keep the sim in single-PDU mode,
|
|
/// which is its known-good envelope.</para>
|
|
///
|
|
/// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL
|
|
/// must result in EXACTLY 1 backend round-trip.</para>
|
|
/// </summary>
|
|
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
|
[Trait("Category", "E2E")]
|
|
public sealed class ResponseCacheE2ETests
|
|
{
|
|
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
|
public ResponseCacheE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
private static int PickFreePort()
|
|
{
|
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
|
l.Start();
|
|
int p = ((IPEndPoint)l.LocalEndpoint).Port;
|
|
l.Stop();
|
|
return p;
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
}
|
|
|
|
// ── Headline test: 10 reads at 100 ms intervals → exactly 1 backend round-trip ──
|
|
|
|
/// <summary>
|
|
/// The "is the design pivot worth it?" test. Configure a BCD tag with <c>CacheTtlMs =
|
|
/// 1000</c>; issue 10 reads at 100 ms intervals through the proxy. The cache HitCount
|
|
/// must show 9 (one miss to prime, 9 hits to serve) and the backend trip count must
|
|
/// be exactly 1.
|
|
/// </summary>
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task E2E_CacheHit_TenReadsIn1Sec_BackendSeesOneRoundTrip()
|
|
{
|
|
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();
|
|
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
|
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
|
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "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(300, 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);
|
|
|
|
// 10 reads at 100 ms intervals — total elapsed ~900 ms, well within the 1000 ms TTL.
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
|
|
regs[0].ShouldBe((ushort)1234, $"read #{i}: BCD-decoded value must be 1234");
|
|
if (i < 9)
|
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
|
}
|
|
}
|
|
|
|
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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
|
|
|
// 9 cache hits (the first read populated the cache; the next 9 returned from cache).
|
|
// 1 cache miss (the priming read).
|
|
backend.GetProperty("cacheHitCount").GetInt64()
|
|
.ShouldBe(9, "10 reads with TTL=1000 ms at 100 ms intervals must produce 9 cache hits");
|
|
backend.GetProperty("cacheMissCount").GetInt64()
|
|
.ShouldBe(1, "exactly the first read should miss");
|
|
|
|
// The backend-trip count is observable via coalescedMissCount (every read that
|
|
// makes it to the backend increments this counter; cache hits short-circuit).
|
|
backend.GetProperty("coalescedMissCount").GetInt64()
|
|
.ShouldBe(1, "exactly one read must reach the backend");
|
|
}
|
|
|
|
// ── Regression: cache disabled by default ────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Mandatory regression. With no cache config anywhere (default deployment shape),
|
|
/// behaviour must be byte-identical to the non-cached path: sequential reads through
|
|
/// the same client produce one backend round-trip each — no elision.
|
|
/// </summary>
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10()
|
|
{
|
|
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();
|
|
// No Cache section, no CacheTtlMs on any tag — non-cached behaviour.
|
|
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
|
config["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(300, 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);
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
|
|
regs[0].ShouldBe((ushort)1234, $"read #{i} must still BCD-decode correctly");
|
|
}
|
|
}
|
|
|
|
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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
|
|
|
backend.GetProperty("cacheHitCount").GetInt64()
|
|
.ShouldBe(0, "no cache config: HitCount must remain at zero");
|
|
backend.GetProperty("cacheMissCount").GetInt64()
|
|
.ShouldBe(0, "no cache config: MissCount must remain at zero — cache counters are tracked only for cache-eligible reads");
|
|
backend.GetProperty("coalescedMissCount").GetInt64()
|
|
.ShouldBe(5, "every read must reach the backend as before — Phase-10 behaviour preserved");
|
|
}
|
|
|
|
// ── TTL expiry path ─────────────────────────────────────────────────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task E2E_CacheExpires_AfterTtl_NextReadHitsBackend()
|
|
{
|
|
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();
|
|
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
|
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
|
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "200";
|
|
|
|
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(300, 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);
|
|
|
|
_ = master.ReadHoldingRegisters(1, 1072, 1); // miss, populates cache
|
|
_ = master.ReadHoldingRegisters(1, 1072, 1); // hit
|
|
await Task.Delay(350, TestContext.Current.CancellationToken); // > TTL
|
|
_ = master.ReadHoldingRegisters(1, 1072, 1); // miss again
|
|
}
|
|
|
|
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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
|
|
|
backend.GetProperty("cacheHitCount").GetInt64()
|
|
.ShouldBe(1, "exactly one read should land inside the TTL window");
|
|
backend.GetProperty("cacheMissCount").GetInt64()
|
|
.ShouldBe(2, "two reads should miss (initial fill and post-expiry refill)");
|
|
backend.GetProperty("coalescedMissCount").GetInt64()
|
|
.ShouldBe(2, "the two cache misses must each produce a backend round-trip");
|
|
}
|
|
|
|
// ── Write invalidation ───────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Uses a register OUTSIDE the simulator's seeded BCD range so subsequent tests'
|
|
/// reads of register 1072 are not polluted by this test's write. The simulator's
|
|
/// holding-register table is shared across tests in the collection.
|
|
/// </summary>
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task E2E_WriteInvalidatesOverlappingCacheEntries()
|
|
{
|
|
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
|
|
|
// Use a scratch register (200) from the simulator's allowed-write range so the
|
|
// FC06 write does not fault on the simulator side, but a register that no other
|
|
// test reads with BCD decoding — its initial value 0 round-trips through the
|
|
// BCD codec without surprises, and any post-write state stays contained.
|
|
const ushort isolatedRegister = 200;
|
|
int proxyPort = PickFreePort();
|
|
int adminPort = PickFreePort();
|
|
var config = MakeBaseConfig(proxyPort);
|
|
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
|
config["Mbproxy:BcdTags:Global:0:Address"] = isolatedRegister.ToString();
|
|
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
|
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";
|
|
|
|
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(300, 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);
|
|
|
|
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // miss → cached
|
|
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // hit
|
|
master.WriteSingleRegister(1, isolatedRegister, 4321); // invalidates the cached entry
|
|
_ = master.ReadHoldingRegisters(1, isolatedRegister, 1); // must miss again
|
|
}
|
|
|
|
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 backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");
|
|
|
|
backend.GetProperty("cacheHitCount").GetInt64()
|
|
.ShouldBe(1, "the second read should hit the cache");
|
|
backend.GetProperty("cacheMissCount").GetInt64()
|
|
.ShouldBe(2, "first read primes the cache; third read misses because the write invalidated the entry");
|
|
backend.GetProperty("cacheInvalidations").GetInt64()
|
|
.ShouldBe(1, "the FC06 write must invalidate exactly one cache entry");
|
|
}
|
|
|
|
// ── BCD-decoded bytes are cached ─────────────────────────────────────────
|
|
|
|
[Fact(Timeout = 5_000)]
|
|
public async Task E2E_BcdDecodedBytesAreCached_NotRawBcd()
|
|
{
|
|
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
|
|
|
int proxyPort = PickFreePort();
|
|
var config = MakeBaseConfig(proxyPort);
|
|
config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
|
|
config["Mbproxy:BcdTags:Global:0:Width"] = "16";
|
|
config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";
|
|
|
|
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(300, 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[] r1 = master.ReadHoldingRegisters(1, 1072, 1);
|
|
ushort[] r2 = master.ReadHoldingRegisters(1, 1072, 1);
|
|
ushort[] r3 = master.ReadHoldingRegisters(1, 1072, 1);
|
|
|
|
r1[0].ShouldBe((ushort)1234, "first read must be BCD-decoded");
|
|
r2[0].ShouldBe((ushort)1234, "second read (cache hit) must return decoded 1234, not raw BCD 0x1234");
|
|
r3[0].ShouldBe((ushort)1234, "third read (cache hit) must return decoded 1234");
|
|
}
|
|
}
|