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(); } } }