554b05d28c
Reviewed the new SignalR dashboard and fixed its two top findings: a stored XSS on the connection-detail page (unescaped tag name / direction / timestamp rendered into innerHTML) and FC03/FC04 cache hits bypassing the debug-view capture, which left cached tags frozen while their age climbed. Also adds an optional human-friendly Name to BCD tags surfaced on the debug view, and loads the real fleet config from tags.txt (12 named BCD tags, PLC Z28061) so the published appsettings.json is deploy-ready. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
617 lines
26 KiB
C#
617 lines
26 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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<byte[]> 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<byte[]> 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<byte>();
|
|
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),
|
|
];
|
|
|
|
/// <summary>
|
|
/// Stub backend that responds immediately with a configurable register value. Records
|
|
/// every backend request it receives so the test can count round-trips.
|
|
/// </summary>
|
|
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<Task> _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<PlcMultiplexer>.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();
|
|
}
|
|
}
|
|
}
|