Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/Multiplexing/PlcMultiplexerTests.cs
T
Joseph Doherty ce32c5cee8 mbproxy: Wave 1 fixes from 2026-05-14 code review
Resolves the four critical correctness defects + the ShutdownCoordinator
double-stop ordering bug called out in codereviews/2026-05-14/Overview.md.
Tests: 362 pass / 0 fail (baseline 358 + 4 new W1 regression tests).

W1.1 — Context swap on running multiplexer.
  PlcMultiplexer._ctx becomes volatile with a new ReplaceContext() method
  that re-registers the cache stats provider on the (preserved) counters.
  PlcListener exposes its multiplexer; PlcListenerSupervisor.ReplaceContextAsync
  swaps the running mux first, then disposes the old cache. Hot-reload
  tag-list changes and the cache-flush-on-reload contract now actually take
  effect on the next PDU instead of waiting for the next listener fault.

W1.2 — Coalescing factory leak.
  When the InFlightByKey factory soft-fails (allocator saturation or duplicate
  TxId), the cleanup path now TryRemoves the stub and walks every party on it
  (including late attachers) to deliver Modbus exception 0x04. Previously
  only the leader got the exception; late attachers waited forever for a
  response that no backend round-trip would ever fire.

W1.3 — Backend-reader head-of-line block.
  UpstreamPipe gains TrySendResponse for non-blocking enqueue. The per-PLC
  backend reader's fan-out loop uses it instead of awaiting SendResponseAsync,
  so a wedged upstream's full bounded response channel can no longer stall
  the single backend reader and starve every other client on that PLC. New
  responseDropForFullUpstream counter on ProxyCounters / CounterSnapshot
  records the drops.

W1.4 — Stranded outbound frames after cascade.
  TearDownBackendAsync acquires _connectGate and drains any frames left in
  _outboundChannel after the writer task faulted/cancelled, releasing their
  proxy TxIds back to the allocator. Without this, a fresh
  EnsureBackendConnectedAsync racing the cascade would send stranded frames
  with old TxIds onto the new backend socket; the responses would arrive
  with no correlation entry and the upstream peers would hang on the
  watchdog until BackendRequestTimeoutMs.

W1.5 — Delete ShutdownCoordinator (Option B).
  Drain logic moved into ProxyWorker.StopAsync. AdminEndpointHost is no
  longer registered as IHostedService; ProxyWorker drives its lifecycle
  directly so admin starts after listeners are bound and stops AFTER the
  in-flight drain (the design's documented contract). Admin is resolved
  lazily in ExecuteAsync to break the circular DI graph
  (Admin -> StatusSnapshotBuilder -> ProxyWorker). GracefulShutdownTimeoutMs
  is now read fresh from IOptionsMonitor.CurrentValue at stop time, so a
  hot-reloaded value is honoured. Removes ShutdownCoordinator + tests.

New tests:
  PlcMultiplexerTests.ReplaceContext_NewTagMap_VisibleOnNextPdu
  PlcMultiplexerTests.ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache
  UpstreamPipeTests.TrySendResponse_WhenChannelFull_ReturnsFalse_WithoutBlocking
  UpstreamPipeTests.TrySendResponse_AfterDispose_ReturnsFalse

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:16:13 -04:00

760 lines
31 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.Multiplexing;
/// <summary>
/// Integration tests for <see cref="PlcMultiplexer"/> against a stub backend
/// (a <see cref="TcpListener"/> that canned-responds). Uses real sockets but no simulator.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PlcMultiplexerTests
{
// ── Helpers ────────────────────────────────────────────────────────────────
private static int PickFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
int port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
/// <summary>
/// Reads exactly <paramref name="count"/> bytes from <paramref name="socket"/>.
/// </summary>
private static async Task<byte[]> ReadExactAsync(Socket socket, int count, CancellationToken ct)
{
var buf = new byte[count];
int read = 0;
while (read < count)
{
int n = await socket.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 socket, CancellationToken ct)
{
var header = await ReadExactAsync(socket, 7, ct);
ushort length = (ushort)((header[4] << 8) | header[5]);
int bodyLen = length - 1;
var body = bodyLen > 0 ? await ReadExactAsync(socket, 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[] BuildFc03ReadFrame(ushort txId, ushort start, ushort qty, byte unitId = 1)
=>
[
(byte)(txId >> 8), (byte)(txId & 0xFF),
0x00, 0x00,
0x00, 0x06,
unitId,
0x03,
(byte)(start >> 8), (byte)(start & 0xFF),
(byte)(qty >> 8), (byte)(qty & 0xFF),
];
private static byte[] BuildFc06WriteFrame(ushort txId, ushort addr, ushort value, byte unitId = 1)
=>
[
(byte)(txId >> 8), (byte)(txId & 0xFF),
0x00, 0x00,
0x00, 0x06,
unitId,
0x06,
(byte)(addr >> 8), (byte)(addr & 0xFF),
(byte)(value >> 8), (byte)(value & 0xFF),
];
private static byte[] BuildFc03Response(ushort txId, byte unitId, params ushort[] registers)
{
int bodyLen = 2 + registers.Length * 2; // FC + byteCount + register data
var frame = new byte[7 + bodyLen];
frame[0] = (byte)(txId >> 8);
frame[1] = (byte)(txId & 0xFF);
frame[2] = 0;
frame[3] = 0;
ushort length = (ushort)(1 + bodyLen); // UnitId + PDU
frame[4] = (byte)(length >> 8);
frame[5] = (byte)(length & 0xFF);
frame[6] = unitId;
frame[7] = 0x03;
frame[8] = (byte)(registers.Length * 2);
for (int i = 0; i < registers.Length; i++)
{
frame[9 + i * 2] = (byte)(registers[i] >> 8);
frame[9 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
}
return frame;
}
/// <summary>
/// FC06 response echo with txId / addr / value.
/// </summary>
private static byte[] BuildFc06Response(ushort txId, byte unitId, ushort addr, ushort value)
{
var frame = new byte[7 + 5];
frame[0] = (byte)(txId >> 8);
frame[1] = (byte)(txId & 0xFF);
frame[2] = 0; frame[3] = 0;
frame[4] = 0; frame[5] = 6; // length: UnitId(1) + FC(1) + Addr(2) + Value(2)
frame[6] = unitId;
frame[7] = 0x06;
frame[8] = (byte)(addr >> 8);
frame[9] = (byte)(addr & 0xFF);
frame[10] = (byte)(value >> 8);
frame[11] = (byte)(value & 0xFF);
return frame;
}
private static PerPlcContext MakeContext(string name, 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,
};
}
/// <summary>
/// A stub backend that echoes FC03 responses for every request, recording the proxy
/// TxIds it sees on the wire so tests can verify the multiplexer rewrites them.
/// </summary>
private sealed class StubBackend : IAsyncDisposable
{
public int Port { get; }
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _clientTasks = new();
public ConcurrentQueue<ushort> SeenProxyTxIds { get; } = new();
public Func<byte, ushort, ushort, ushort, byte[]>? FcResponseFactory { get; set; }
public StubBackend(int port)
{
Port = port;
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
_ = AcceptLoop();
}
private async Task AcceptLoop()
{
try
{
while (!_cts.IsCancellationRequested)
{
Socket s = await _listener.AcceptSocketAsync(_cts.Token);
var task = Task.Run(() => HandleAsync(s));
lock (_clientTasks) _clientTasks.Add(task);
}
}
catch { /* shutdown */ }
}
private async Task HandleAsync(Socket s)
{
try
{
while (!_cts.IsCancellationRequested)
{
var req = await ReadOneFrameAsync(s, _cts.Token);
if (req.Length < 8) break;
ushort txId = (ushort)((req[0] << 8) | req[1]);
SeenProxyTxIds.Enqueue(txId);
byte unitId = req[6];
byte fc = req[7];
byte[] response;
if (FcResponseFactory is not null)
{
ushort start = req.Length >= 10 ? (ushort)((req[8] << 8) | req[9]) : (ushort)0;
ushort qty = req.Length >= 12 ? (ushort)((req[10] << 8) | req[11]) : (ushort)0;
response = FcResponseFactory(fc, start, qty, txId);
}
else if (fc == 0x03)
{
// Default: FC03 echo a single register containing 0x1234.
response = BuildFc03Response(txId, unitId, 0x1234);
}
else if (fc == 0x06)
{
ushort addr = (ushort)((req[8] << 8) | req[9]);
ushort value = (ushort)((req[10] << 8) | req[11]);
response = BuildFc06Response(txId, unitId, addr, value);
}
else
{
break;
}
await s.SendAsync(response, SocketFlags.None, _cts.Token);
}
}
catch { /* normal */ }
finally { try { s.Dispose(); } catch { } }
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try { _listener.Stop(); } catch { }
Task[] snap;
lock (_clientTasks) snap = _clientTasks.ToArray();
try { await Task.WhenAll(snap).WaitAsync(TimeSpan.FromSeconds(2)); } catch { }
_cts.Dispose();
}
}
private static async Task<PlcMultiplexer> BuildMuxAsync(
PlcOptions plc, ConnectionOptions connOpts, PerPlcContext ctx)
{
var mux = new PlcMultiplexer(
plc, connOpts,
new BcdPduPipeline(),
ctx,
NullLogger<PlcMultiplexer>.Instance,
backendConnectPipeline: null);
await Task.Yield();
return mux;
}
private static async Task<(Socket client, UpstreamPipe pipe, TcpListener proxyListener, int proxyPort)>
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, proxyPort);
}
// ── Tests ─────────────────────────────────────────────────────────────────
[Fact]
public async Task SingleUpstream_RoundTripsFC03_Through_Multiplexer()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
var ctx = MakeContext("PLC1", BcdTag.Create(100, 16));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
try
{
await client.SendAsync(BuildFc03ReadFrame(0x1234, 100, 1), SocketFlags.None);
var rsp = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
ushort rspTxId = (ushort)((rsp[0] << 8) | rsp[1]);
rspTxId.ShouldBe((ushort)0x1234, "the original TxId must be restored on the way back to the client");
// BCD decode of the stub's 0x1234 response = 1234.
ushort decoded = (ushort)((rsp[9] << 8) | rsp[10]);
decoded.ShouldBe((ushort)1234);
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
listener.Stop();
}
}
[Fact]
public async Task SingleUpstream_RoundTripsFC06_Through_Multiplexer()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
var ctx = MakeContext("PLC1", BcdTag.Create(200, 16));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Client writes binary 1234; proxy encodes to BCD 0x1234 on the way out.
await client.SendAsync(BuildFc06WriteFrame(0xABCD, 200, 1234), SocketFlags.None);
var rsp = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
ushort rspTxId = (ushort)((rsp[0] << 8) | rsp[1]);
rspTxId.ShouldBe((ushort)0xABCD);
// Echo bytes decoded back to client binary.
ushort echoed = (ushort)((rsp[10] << 8) | rsp[11]);
echoed.ShouldBe((ushort)1234);
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
listener.Stop();
}
}
[Fact]
public async Task TwoUpstreams_ConcurrentFC03_BothGetCorrectResponses()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort)
{
// Both clients read address 100; both should see their own TxId echoed.
FcResponseFactory = (fc, start, qty, txId) =>
{
byte unitId = 1;
return fc == 0x03
? BuildFc03Response(txId, unitId, 0x1234)
: throw new InvalidOperationException("unexpected fc");
},
};
var ctx = MakeContext("PLC1", BcdTag.Create(100, 16));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (c1, p1, l1, _) = await ConnectClientAsync(mux, plc.Name);
var (c2, p2, l2, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Both clients use the same upstream TxId (0x0001). That would clash on a
// shared backend wire if the mux didn't rewrite the TxId.
await c1.SendAsync(BuildFc03ReadFrame(0x0001, 100, 1), SocketFlags.None);
await c2.SendAsync(BuildFc03ReadFrame(0x0001, 100, 1), SocketFlags.None);
var r1 = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
var r2 = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
// Both responses must carry the original (colliding) TxId.
((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001);
((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0001);
}
finally
{
c1.Dispose(); c2.Dispose();
await p1.DisposeAsync(); await p2.DisposeAsync();
l1.Stop(); l2.Stop();
}
}
[Fact]
public async Task TwoUpstreams_ProxyTxIds_AreDistinct_OnTheWire()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
var ctx = MakeContext("PLC1");
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (c1, p1, l1, _) = await ConnectClientAsync(mux, plc.Name);
var (c2, p2, l2, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Both clients use the same upstream TxId 0x0007 — the proxy must hand out
// distinct proxy TxIds on the backend wire. Phase 10: reads target DIFFERENT
// addresses so coalescing does not fuse them into a single backend request.
await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 10, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(c1, TestContext.Current.CancellationToken);
_ = await ReadOneFrameAsync(c2, TestContext.Current.CancellationToken);
// Collect what the backend saw.
var seen = new HashSet<ushort>(backend.SeenProxyTxIds);
seen.Count.ShouldBeGreaterThanOrEqualTo(2, "the multiplexer must allocate distinct proxy TxIds even when upstreams collide");
}
finally
{
c1.Dispose(); c2.Dispose();
await p1.DisposeAsync(); await p2.DisposeAsync();
l1.Stop(); l2.Stop();
}
}
[Fact]
public async Task UpstreamDisconnect_DoesNotAffectOtherUpstreams()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
var ctx = MakeContext("PLC1");
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (cA, pA, lA, _) = await ConnectClientAsync(mux, plc.Name);
var (cB, pB, lB, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Drop client A entirely.
cA.Dispose();
await Task.Delay(50, TestContext.Current.CancellationToken);
// Client B should still be able to round-trip.
await cB.SendAsync(BuildFc03ReadFrame(0x0042, 0, 1), SocketFlags.None);
var rsp = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
((ushort)((rsp[0] << 8) | rsp[1])).ShouldBe((ushort)0x0042);
}
finally
{
cB.Dispose();
await pA.DisposeAsync(); await pB.DisposeAsync();
lA.Stop(); lB.Stop();
}
}
[Fact]
public async Task BackendDisconnect_CascadesToAllUpstreams()
{
int backendPort = PickFreePort();
var backend = new StubBackend(backendPort);
var ctx = MakeContext("PLC1");
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (cA, pA, lA, _) = await ConnectClientAsync(mux, plc.Name);
var (cB, pB, lB, _) = await ConnectClientAsync(mux, plc.Name);
var (cC, pC, lC, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Force a round-trip on each so backend connect occurs first.
await cA.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
await cB.SendAsync(BuildFc03ReadFrame(2, 0, 1), SocketFlags.None);
await cC.SendAsync(BuildFc03ReadFrame(3, 0, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken);
_ = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
_ = await ReadOneFrameAsync(cC, TestContext.Current.CancellationToken);
// Kill the backend.
await backend.DisposeAsync();
// All three upstream sockets should observe a clean EOF within 500 ms.
var sw = System.Diagnostics.Stopwatch.StartNew();
await WaitForCloseAsync(cA, TestContext.Current.CancellationToken);
await WaitForCloseAsync(cB, TestContext.Current.CancellationToken);
await WaitForCloseAsync(cC, TestContext.Current.CancellationToken);
sw.Stop();
sw.ElapsedMilliseconds.ShouldBeLessThan(2000, "cascade should propagate quickly");
// Poll briefly for the cascade counter — there is an inherent scheduling gap
// between "upstream socket EOF observed" (WaitForCloseAsync returns) and "the
// multiplexer's TearDownBackendAsync increments the counter after awaiting
// every pipe.DisposeAsync". This poll absorbs that scheduling jitter without
// weakening the assertion's semantics — the counter MUST reach 3 (or more)
// because all three upstream pipes were attached when the cascade fired.
long cascades = 0;
for (int i = 0; i < 50; i++)
{
cascades = ctx.Counters.Snapshot().BackendDisconnectCascades;
if (cascades >= 3) break;
await Task.Delay(20, TestContext.Current.CancellationToken);
}
cascades.ShouldBeGreaterThanOrEqualTo(3);
}
finally
{
cA.Dispose(); cB.Dispose(); cC.Dispose();
await pA.DisposeAsync(); await pB.DisposeAsync(); await pC.DisposeAsync();
lA.Stop(); lB.Stop(); lC.Stop();
}
}
[Fact]
public async Task RequestTimeoutWatchdog_DeliversException0B_ToUpstream_WhenBackendNeverResponds()
{
// A drain-only stub that consumes requests but never responds. The multiplexer's
// per-request watchdog must surface a Modbus exception 0x0B to the upstream client
// once BackendRequestTimeoutMs elapses, freeing the proxy TxId + correlation entry.
int backendPort = PickFreePort();
var drainListener = new TcpListener(IPAddress.Loopback, backendPort);
drainListener.Start();
var drainCts = new CancellationTokenSource();
var drainToken = drainCts.Token;
_ = Task.Run(async () =>
{
try
{
while (!drainToken.IsCancellationRequested)
{
var s = await drainListener.AcceptSocketAsync(drainToken);
_ = Task.Run(async () =>
{
var buf = new byte[256];
try
{
while (!drainToken.IsCancellationRequested)
{
int n = await s.ReceiveAsync(buf, SocketFlags.None, drainToken);
if (n == 0) break;
}
}
catch { }
finally { try { s.Dispose(); } catch { } }
}, drainToken);
}
}
catch { }
}, drainToken);
try
{
var ctx = MakeContext("PLC1");
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
// Short request timeout so the test does not have to wait long.
var connOpts = new ConnectionOptions { BackendRequestTimeoutMs = 400 };
await using var mux = await BuildMuxAsync(plc, connOpts, ctx);
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
try
{
await client.SendAsync(BuildFc03ReadFrame(0xABCD, 0, 1), SocketFlags.None);
// The watchdog should deliver an exception within ~watchdog-tick * 2.
var rsp = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
ushort rspTxId = (ushort)((rsp[0] << 8) | rsp[1]);
rspTxId.ShouldBe((ushort)0xABCD, "watchdog must echo the original client TxId");
byte fcByte = rsp[7];
(fcByte & 0x80).ShouldBe(0x80, "FC must have the exception bit set");
(fcByte & 0x7F).ShouldBe(0x03, "original FC must be FC03 (read holding registers)");
rsp[8].ShouldBe((byte)0x0B, "exception code must be 0x0B (Gateway Target Device Failed To Respond)");
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
listener.Stop();
}
}
finally
{
await drainCts.CancelAsync();
try { drainListener.Stop(); } catch { }
drainCts.Dispose();
}
}
[Fact]
public async Task BackendReconnect_AfterCascade_NextUpstreamRequest_Succeeds()
{
int backendPort = PickFreePort();
var backend = new StubBackend(backendPort);
var ctx = MakeContext("PLC1");
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx);
var (cA, pA, lA, _) = await ConnectClientAsync(mux, plc.Name);
try
{
await cA.SendAsync(BuildFc03ReadFrame(1, 0, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(cA, TestContext.Current.CancellationToken);
await backend.DisposeAsync();
await WaitForCloseAsync(cA, TestContext.Current.CancellationToken);
cA.Dispose();
await pA.DisposeAsync();
lA.Stop();
}
catch { /* tolerate any teardown noise */ }
// Start a new backend on the same port.
await using var backend2 = new StubBackend(backendPort);
// A fresh client should round-trip cleanly through the same multiplexer.
var (cB, pB, lB, _) = await ConnectClientAsync(mux, plc.Name);
try
{
await cB.SendAsync(BuildFc03ReadFrame(0x7777, 0, 1), SocketFlags.None);
var rsp = await ReadOneFrameAsync(cB, TestContext.Current.CancellationToken);
((ushort)((rsp[0] << 8) | rsp[1])).ShouldBe((ushort)0x7777);
}
finally
{
cB.Dispose();
await pB.DisposeAsync();
lB.Stop();
}
}
private static async Task WaitForCloseAsync(Socket s, CancellationToken ct)
{
var buf = new byte[1];
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(TimeSpan.FromSeconds(2));
while (!deadline.IsCancellationRequested)
{
try
{
int n = await s.ReceiveAsync(buf, SocketFlags.None, deadline.Token);
if (n == 0) return;
}
catch
{
return;
}
}
}
// ── Phase 12 Wave-1 regression tests ──────────────────────────────────────
/// <summary>
/// W1.1 — verifies that <see cref="PlcMultiplexer.ReplaceContext"/> swaps the live
/// per-PLC context on the running multiplexer, so the very next PDU's BCD rewriter
/// uses the new tag map (not the captured-at-construction map). Before W1.1 this
/// scenario would silently keep using the old map until the listener faulted and the
/// supervisor's Polly loop reconstructed everything.
/// </summary>
[Fact]
public async Task ReplaceContext_NewTagMap_VisibleOnNextPdu()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
backend.FcResponseFactory = (fc, _, _, txId) =>
fc == 0x03 ? BuildFc03Response(txId, 1, 0x1234) : Array.Empty<byte>();
// Context 1 — tag at addr 100, BCD16. Wire 0x1234 decodes to decimal 1234.
var ctx1 = MakeContext("PLC1", BcdTag.Create(100, 16));
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx1);
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Read 1 with original ctx — wire 0x1234 should be decoded to 1234 (= 0x04D2).
await client.SendAsync(BuildFc03ReadFrame(1, 100, 1), SocketFlags.None);
var rsp1 = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
ushort decoded1 = (ushort)((rsp1[9] << 8) | rsp1[10]);
decoded1.ShouldBe((ushort)1234, "with tag at 100, BCD wire 0x1234 must decode to decimal 1234");
// Swap to an empty tag map (counters preserved per the design's reseat contract).
var ctx2 = new PerPlcContext
{
PlcName = "PLC1",
TagMap = BcdTagMap.Empty,
Counters = ctx1.Counters,
Logger = NullLogger.Instance,
};
mux.ReplaceContext(ctx2);
// Read 2 with swapped ctx — no tag, raw 0x1234 must pass through unchanged.
await client.SendAsync(BuildFc03ReadFrame(2, 100, 1), SocketFlags.None);
var rsp2 = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
ushort raw2 = (ushort)((rsp2[9] << 8) | rsp2[10]);
raw2.ShouldBe((ushort)0x1234,
"after ReplaceContext to empty tag map, the next PDU must use the new map and pass 0x1234 through unchanged");
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
listener.Stop();
}
}
/// <summary>
/// W1.1 — verifies that swapping in a fresh response cache via <see cref="PlcMultiplexer.ReplaceContext"/>
/// makes the running multiplexer consult the NEW cache for subsequent reads, not the
/// old cache that was disposed by the supervisor. Without W1.1 the running mux would
/// keep its constructor-captured cache reference and either return stale entries or
/// hit a disposed cache.
/// </summary>
[Fact]
public async Task ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache()
{
int backendPort = PickFreePort();
await using var backend = new StubBackend(backendPort);
backend.FcResponseFactory = (fc, _, _, txId) =>
fc == 0x03 ? BuildFc03Response(txId, 1, (ushort)0x1111) : Array.Empty<byte>();
// Context 1 — cacheable tag at addr 200 with TTL 60_000 ms.
var tag = new BcdTag(200, 16, CacheTtlMs: 60_000);
var dict = new[] { tag }.ToDictionary(t => t.Address).ToFrozenDictionary();
var map = new BcdTagMap(dict);
var cache1 = new ResponseCache(maxEntriesPerPlc: 100, evictionIntervalMs: 5000);
var ctx1 = new PerPlcContext
{
PlcName = "PLC1",
TagMap = map,
Counters = new ProxyCounters(),
Logger = NullLogger.Instance,
Cache = cache1,
};
var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
await using var mux = await BuildMuxAsync(plc, new ConnectionOptions(), ctx1);
var (client, pipe, listener, _) = await ConnectClientAsync(mux, plc.Name);
try
{
// Read 1 — populates cache1 from backend.
await client.SendAsync(BuildFc03ReadFrame(1, 200, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
await Task.Delay(50, TestContext.Current.CancellationToken);
int afterFirst = backend.SeenProxyTxIds.Count;
cache1.Count.ShouldBe(1, "cache1 must contain the first read");
// Read 2 — must hit cache1 (no backend traffic).
await client.SendAsync(BuildFc03ReadFrame(2, 200, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
backend.SeenProxyTxIds.Count.ShouldBe(afterFirst, "cache hit must not produce backend traffic");
// Swap in a brand-new (empty) cache via ReplaceContext.
var cache2 = new ResponseCache(maxEntriesPerPlc: 100, evictionIntervalMs: 5000);
var ctx2 = new PerPlcContext
{
PlcName = "PLC1",
TagMap = map,
Counters = ctx1.Counters,
Logger = NullLogger.Instance,
Cache = cache2,
};
mux.ReplaceContext(ctx2);
// Read 3 — old cache had the entry, but mux now uses cache2 which is empty,
// so the next read MUST go to the backend.
await client.SendAsync(BuildFc03ReadFrame(3, 200, 1), SocketFlags.None);
_ = await ReadOneFrameAsync(client, TestContext.Current.CancellationToken);
await Task.Delay(50, TestContext.Current.CancellationToken);
backend.SeenProxyTxIds.Count.ShouldBe(afterFirst + 1,
"after ReplaceContext, the running multiplexer must consult the NEW cache (empty) — not the old one (still warm)");
cache2.Count.ShouldBe(1, "the new cache should be populated by the post-swap read");
}
finally
{
client.Dispose();
await pipe.DisposeAsync();
listener.Stop();
cache1.Dispose();
}
}
}