using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Net;
using System.Net.Sockets;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Cache;
using Mbproxy.Proxy.Multiplexing;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Proxy.Cache;
///
/// Phase-11 cache wiring inside the multiplexer, exercised against a stub backend with
/// deterministic response timing. Stub-backend tests are the "is the cache wired correctly"
/// proof — they cover behaviour the simulator-backed E2E suite cannot exercise reliably
/// (true concurrent reads through the cache; cross-PLC isolation).
///
[Trait("Category", "Unit")]
public sealed class ResponseCacheMultiplexerTests
{
// ── Frame 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 static async Task ReadExactAsync(Socket s, int count, CancellationToken ct)
{
var buf = new byte[count];
int read = 0;
while (read < count)
{
int n = await s.ReceiveAsync(buf.AsMemory(read, count - read), SocketFlags.None, ct);
if (n == 0) throw new IOException("EOF");
read += n;
}
return buf;
}
private static async Task ReadOneFrameAsync(Socket s, CancellationToken ct)
{
var header = await ReadExactAsync(s, 7, ct);
ushort length = (ushort)((header[4] << 8) | header[5]);
int bodyLen = length - 1;
var body = bodyLen > 0 ? await ReadExactAsync(s, bodyLen, ct) : Array.Empty();
var frame = new byte[7 + bodyLen];
Buffer.BlockCopy(header, 0, frame, 0, 7);
if (bodyLen > 0) Buffer.BlockCopy(body, 0, frame, 7, bodyLen);
return frame;
}
private static byte[] BuildFc03(ushort txId, ushort start, ushort qty, byte unit = 1)
=> [
(byte)(txId >> 8), (byte)(txId & 0xFF),
0x00, 0x00,
0x00, 0x06,
unit, 0x03,
(byte)(start >> 8), (byte)(start & 0xFF),
(byte)(qty >> 8), (byte)(qty & 0xFF),
];
private static byte[] BuildFc06(ushort txId, ushort addr, ushort value, byte unit = 1)
=> [
(byte)(txId >> 8), (byte)(txId & 0xFF),
0x00, 0x00,
0x00, 0x06,
unit, 0x06,
(byte)(addr >> 8), (byte)(addr & 0xFF),
(byte)(value >> 8), (byte)(value & 0xFF),
];
///
/// Stub backend that responds immediately with a configurable register value. Records
/// every backend request it receives so the test can count round-trips.
///
private sealed class StubBackend : IAsyncDisposable
{
public int Port { get; }
public int RequestCount => _requestCount;
public ushort RegisterValue { get; set; } = 0x1234;
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly List _tasks = new();
private int _requestCount;
public StubBackend(int port)
{
Port = port;
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
_ = AcceptLoop();
}
private async Task AcceptLoop()
{
try
{
while (!_cts.IsCancellationRequested)
{
var s = await _listener.AcceptSocketAsync(_cts.Token);
var t = Task.Run(() => HandleAsync(s));
lock (_tasks) _tasks.Add(t);
}
}
catch { }
}
private async Task HandleAsync(Socket s)
{
try
{
while (!_cts.IsCancellationRequested)
{
var req = await ReadOneFrameAsync(s, _cts.Token);
if (req.Length < 8) break;
Interlocked.Increment(ref _requestCount);
ushort txId = (ushort)((req[0] << 8) | req[1]);
byte unit = req[6];
byte fc = req[7];
byte[] response;
if (fc == 0x03 || fc == 0x04)
{
ushort qty = (ushort)((req[10] << 8) | req[11]);
int byteCount = qty * 2;
response = new byte[7 + 2 + byteCount];
response[0] = (byte)(txId >> 8);
response[1] = (byte)(txId & 0xFF);
response[2] = 0; response[3] = 0;
ushort len = (ushort)(1 + 2 + byteCount);
response[4] = (byte)(len >> 8);
response[5] = (byte)(len & 0xFF);
response[6] = unit;
response[7] = fc;
response[8] = (byte)byteCount;
for (int i = 0; i < qty; i++)
{
response[9 + i * 2] = (byte)(RegisterValue >> 8);
response[9 + i * 2 + 1] = (byte)(RegisterValue & 0xFF);
}
}
else if (fc == 0x06)
{
ushort addr = (ushort)((req[8] << 8) | req[9]);
ushort val = (ushort)((req[10] << 8) | req[11]);
response = new byte[12];
response[0] = (byte)(txId >> 8);
response[1] = (byte)(txId & 0xFF);
response[2] = 0; response[3] = 0;
response[4] = 0; response[5] = 6;
response[6] = unit; response[7] = 0x06;
response[8] = (byte)(addr >> 8); response[9] = (byte)(addr & 0xFF);
response[10] = (byte)(val >> 8); response[11] = (byte)(val & 0xFF);
}
else { break; }
await s.SendAsync(response, SocketFlags.None, _cts.Token);
}
}
catch { }
finally { try { s.Dispose(); } catch { } }
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try { _listener.Stop(); } catch { }
Task[] snap;
lock (_tasks) snap = _tasks.ToArray();
try { await Task.WhenAll(snap).WaitAsync(TimeSpan.FromSeconds(2)); } catch { }
_cts.Dispose();
}
}
private static PerPlcContext MakeContext(string name, ResponseCache? cache, params BcdTag[] tags)
{
var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
return new PerPlcContext
{
PlcName = name,
TagMap = map,
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
Cache = cache,
};
}
private static PlcMultiplexer BuildMux(PlcOptions plc, PerPlcContext ctx, bool coalescingEnabled = true)
{
return new PlcMultiplexer(
plc, new ConnectionOptions(),
new BcdPduPipeline(),
ctx,
NullLogger.Instance,
backendConnectPipeline: null,
coalescingOptions: () => new ReadCoalescingOptions { Enabled = coalescingEnabled, MaxParties = 32 });
}
private static async Task<(Socket client, UpstreamPipe pipe, TcpListener proxyListener)>
ConnectClientAsync(PlcMultiplexer mux, string plcName)
{
int proxyPort = PickFreePort();
var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
proxyListener.Start();
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
{ NoDelay = true };
await client.ConnectAsync(IPAddress.Loopback, proxyPort);
var upstream = await proxyListener.AcceptSocketAsync();
var pipe = new UpstreamPipe(upstream, plcName, NullLogger.Instance);
_ = Task.Run(() => mux.StartPipeAsync(pipe, CancellationToken.None));
return (client, pipe, proxyListener);
}
// ── Tests ────────────────────────────────────────────────────────────────────
[Fact]
public async Task SecondRead_OfSameKey_WithinTtl_HitsCache_NoSecondBackendRoundTrip()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
// 16-bit BCD tag at address 100 with 5 s TTL.
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// First read — miss, hits backend.
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
var r1 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001);
// Second read same key — hit, no second round-trip.
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
var r2 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0002,
"the cache hit must restore the requesting client's original TxId");
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
var snap = ctx.Counters.Snapshot();
snap.CacheHitCount.ShouldBe(1);
snap.CacheMissCount.ShouldBe(1);
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task BcdDecodedBytes_AreCached_NotRawBcd()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort) { RegisterValue = 0x1234 }; // raw BCD nibbles
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
var r1 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
// Response: [..mbap..][0x03][byteCount=2][hi][lo]
ushort decoded1 = (ushort)((r1[9] << 8) | r1[10]);
decoded1.ShouldBe((ushort)1234, "first read must be BCD-decoded by the rewriter");
// Now read again — must be served from cache and still show 1234 (not 0x1234).
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
var r2 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
ushort decoded2 = (ushort)((r2[9] << 8) | r2[10]);
decoded2.ShouldBe((ushort)1234,
"cache must store POST-rewriter bytes — hits must not re-decode and must not return raw BCD");
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task CacheHit_ShortCircuits_Coalescing()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx, coalescingEnabled: true);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// Prime the cache.
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
// Subsequent read must hit cache; coalescing miss-counter must NOT increment
// (cache short-circuited before the coalescing path).
long missBefore = ctx.Counters.Snapshot().CoalescedMissCount;
long hitBefore = ctx.Counters.Snapshot().CoalescedHitCount;
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
var snap = ctx.Counters.Snapshot();
snap.CacheHitCount.ShouldBe(1, "second read must hit cache");
(snap.CoalescedMissCount - missBefore).ShouldBe(0,
"cache hit must short-circuit the coalescing path entirely — no Miss recorded");
(snap.CoalescedHitCount - hitBefore).ShouldBe(0,
"cache hit must short-circuit the coalescing path entirely — no Hit recorded");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task Fc06Write_InvalidatesOverlappingCachedRead()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// Cache the read.
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
cache.Count.ShouldBe(1, "first read must populate the cache");
// Write to address 100 — must invalidate the cached read.
await c.SendAsync(BuildFc06(0x0002, 100, 1234), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
// Cache must be empty now.
cache.Count.ShouldBe(0, "write to a cached address must invalidate the entry");
ctx.Counters.Snapshot().CacheInvalidations.ShouldBe(1);
// A subsequent read must miss the cache.
await c.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
backend.RequestCount.ShouldBe(3, "two reads (one miss, one post-invalidate) + one write = 3 backend round-trips");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task NonOverlappingWrite_DoesNotInvalidate()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
var ctx = MakeContext("PLC1", cache,
BcdTag.Create(100, 16, cacheTtlMs: 5000),
BcdTag.Create(200, 16, cacheTtlMs: 5000));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// Cache the read at 100.
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
// Write to address 200 — distinct register; the cached [100..101) must remain.
await c.SendAsync(BuildFc06(0x0002, 200, 7), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
cache.Count.ShouldBe(1, "a disjoint write must not invalidate the cached read");
// Second read on 100 must hit cache.
await c.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
ctx.Counters.Snapshot().CacheHitCount.ShouldBe(1);
backend.RequestCount.ShouldBe(2, "first read + write — second read served from cache");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task MultiTagRange_AnyZeroTtl_DisablesCachingForWholeRead()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
// Two tags in the read range [100..102): tag 100 has a TTL, tag 101 does not.
var ctx = MakeContext("PLC1", cache,
BcdTag.Create(100, 16, cacheTtlMs: 1000),
BcdTag.Create(101, 16, cacheTtlMs: 0));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// Two identical reads — both should hit the backend because tag 101 disables
// caching for the whole [100..102) range.
await c.SendAsync(BuildFc03(0x0001, 100, 2), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
await c.SendAsync(BuildFc03(0x0002, 100, 2), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
backend.RequestCount.ShouldBe(2,
"any TTL=0 in a multi-tag range must disable caching for the whole read");
ctx.Counters.Snapshot().CacheHitCount.ShouldBe(0);
ctx.Counters.Snapshot().CacheMissCount.ShouldBe(0,
"reads with effective TTL = 0 must not increment either cache counter");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task UncachedReads_BehaveIdentically_ToPhase10()
{
// Regression guard: PerPlcContext with Cache = null must behave byte-identically
// to the non-cached path — every FC03 read produces a backend round-trip
// (coalescing aside).
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
// No cache on the context — Cache = null.
var ctx = MakeContext("PLC1", cache: null);
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// Three sequential identical reads — each hits the backend (no coalescing
// window with sequential reads, no cache wired).
for (int i = 0; i < 3; i++)
{
await c.SendAsync(BuildFc03((ushort)(i + 1), 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
}
backend.RequestCount.ShouldBe(3,
"without a cache, every read must hit the backend (Phase-10 behaviour)");
var snap = ctx.Counters.Snapshot();
snap.CacheHitCount.ShouldBe(0);
snap.CacheMissCount.ShouldBe(0,
"cache counters must remain at zero when no cache is wired");
snap.CoalescedMissCount.ShouldBe(3,
"every FC03 read must increment CoalescedMissCount per the Phase-10 contract");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task FailedBackendConnect_OnFirstRead_DoesNotPreventLaterCacheHits_IfCachePrePopulated()
{
// Edge case from the design contract: a cache hit short-circuits backend
// connection establishment. We pre-populate the cache by direct Set, then probe a
// cache hit while the backend is unreachable.
int unreachable = PickFreePort(); // listener never started on this port
using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 60_000));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = unreachable };
// Pre-populate the cache with a synthesised response PDU body. This is what the
// backend reader would have stored after BCD-decoding.
var key = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 1);
var now = DateTimeOffset.UtcNow;
byte[] cachedPdu = [0x03, 0x02, 0x04, 0xD2]; // FC=03, byteCount=2, regValue=0x04D2 (decimal 1234)
cache.Set(key, new CacheEntry(cachedPdu, now, now.AddSeconds(60), cachedPdu.Length, 0));
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// The cache check runs BEFORE EnsureBackendConnectedAsync, so we should get a
// response even though the backend is unreachable.
using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(800));
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None, deadline.Token);
var r1 = await ReadOneFrameAsync(c, deadline.Token);
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001,
"cache hits must serve even when the backend is unreachable");
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
[Fact]
public async Task CacheHit_RecordsServedRead_IntoArmedDebugCapture()
{
// C3 regression guard: a cache hit bypasses the BCD pipeline, so without the
// cache-entry observation replay the connection-detail debug view would freeze
// for the whole TTL on a cached tag. A hit must re-record the served read into
// the (armed) capture so the debug view reflects what the client receives.
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort) { RegisterValue = 0x1234 };
using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
var tag = BcdTag.Create(100, 16, cacheTtlMs: 5000);
// A detail-page viewer has armed this PLC's debug-view capture.
var capture = new TagValueCapture([tag]);
capture.Arm();
var frozen = new[] { tag }.ToDictionary(t => t.Address).ToFrozenDictionary();
var ctx = new PerPlcContext
{
PlcName = "PLC1",
TagMap = new BcdTagMap(frozen),
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
Cache = cache,
Capture = capture,
};
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = BuildMux(plc, ctx);
var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
try
{
// First read — cache miss. The pipeline records the observation and the
// entry is stored with the per-tag observations attached.
await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
var afterMiss = capture.Snapshot().Single(o => o.Address == 100);
afterMiss.UpdatedAtUtc.ShouldNotBeNull("the cache-miss read must record an observation");
afterMiss.DecodedValue.ShouldBe(1234);
afterMiss.RawLow.ShouldBe((ushort)0x1234);
// Clear the capture's slots (models the debug view holding no fresh data),
// then re-arm. Only the cache-hit replay can repopulate slot 100 now — the
// backend is not contacted again.
capture.Disarm();
capture.Arm();
capture.Snapshot().Single(o => o.Address == 100).UpdatedAtUtc
.ShouldBeNull("Disarm must clear the slot");
// Second read — cache hit. No backend round-trip; the pipeline is bypassed.
await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
var afterHit = capture.Snapshot().Single(o => o.Address == 100);
afterHit.UpdatedAtUtc.ShouldNotBeNull(
"a cache hit must re-record the served read so the debug view does not freeze");
afterHit.DecodedValue.ShouldBe(1234, "the replayed observation carries the decoded value");
afterHit.RawLow.ShouldBe((ushort)0x1234, "the replayed observation carries the raw BCD nibbles");
afterHit.Direction.ShouldBe(CaptureDirection.Read);
}
finally
{
c.Dispose();
await p.DisposeAsync();
l.Stop();
}
}
}