mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="CorrelationMap"/>. Pure logic — no I/O.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CorrelationMapTests
|
||||
{
|
||||
private static InFlightRequest MakeReq(byte fc = 0x03, ushort start = 0, ushort qty = 1)
|
||||
=> new(
|
||||
UnitId: 1, Fc: fc, StartAddress: start, Qty: qty,
|
||||
InterestedParties: Array.Empty<InterestedParty>(),
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_Then_TryRemove_RoundTrips()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
var req = MakeReq();
|
||||
|
||||
map.TryAdd(42, req).ShouldBeTrue();
|
||||
map.Count.ShouldBe(1);
|
||||
|
||||
map.TryRemove(42, out var got).ShouldBeTrue();
|
||||
got.ShouldBeSameAs(req);
|
||||
map.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryAdd_DuplicateKey_Fails()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
map.TryAdd(7, MakeReq()).ShouldBeTrue();
|
||||
map.TryAdd(7, MakeReq()).ShouldBeFalse("duplicate key must be rejected");
|
||||
map.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryRemove_OfMissing_ReturnsFalse()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
map.TryRemove(99, out var got).ShouldBeFalse();
|
||||
got.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_ReflectsCurrentState()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
var r1 = MakeReq(start: 10);
|
||||
var r2 = MakeReq(start: 20);
|
||||
map.TryAdd(1, r1).ShouldBeTrue();
|
||||
map.TryAdd(2, r2).ShouldBeTrue();
|
||||
|
||||
var snap = map.Snapshot();
|
||||
snap.Count.ShouldBe(2);
|
||||
snap.ShouldContain(r1);
|
||||
snap.ShouldContain(r2);
|
||||
|
||||
map.TryRemove(1, out _).ShouldBeTrue();
|
||||
|
||||
// Snapshot is a copy; doesn't reflect the removal that happened after Snapshot returned.
|
||||
// Re-snapshot to verify state.
|
||||
map.Snapshot().Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_AddRemove_NoDataLoss_Under_Parallel_Stress()
|
||||
{
|
||||
var map = new CorrelationMap();
|
||||
const int producers = 16;
|
||||
const int opsPerProducer = 4096;
|
||||
|
||||
// Each producer adds a disjoint range and removes it. After all complete, the map
|
||||
// must be empty and no add or remove may have failed for a non-contention reason.
|
||||
await Task.WhenAll(Enumerable.Range(0, producers).Select(p => Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < opsPerProducer; i++)
|
||||
{
|
||||
ushort key = (ushort)((p * opsPerProducer + i) & 0xFFFF);
|
||||
// The 0..65535 range guarantees a few collisions; the test asserts that the
|
||||
// map handles them as documented (TryAdd returns false on duplicate; the
|
||||
// owner removes its own key).
|
||||
if (map.TryAdd(key, MakeReq(start: key)))
|
||||
map.TryRemove(key, out _);
|
||||
}
|
||||
})));
|
||||
|
||||
map.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Mbproxy;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NModbus;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end tests for the Phase-9 TxId multiplexer against the pymodbus DL205 simulator.
|
||||
///
|
||||
/// <para><b>pymodbus 3.13.0 simulator quirk.</b> The simulator's <c>ServerRequestHandler</c>
|
||||
/// stores a single <c>last_pdu</c> field per TCP connection and schedules
|
||||
/// <c>handle_later</c> via <c>asyncio.call_soon</c>. If two MBAP frames arrive in the same
|
||||
/// recv-buffer (which the multiplexer can cause on a shared backend connection), the
|
||||
/// later frame overwrites <c>last_pdu</c> before the first scheduled handler runs,
|
||||
/// and both responses then carry the same TxId. The real DL260 ECOM does not suffer this
|
||||
/// quirk (it properly echoes per-request MBAP TxIds), so this is purely a simulator
|
||||
/// limitation — the multiplexer's TxId rewriting is verified end-to-end against a stub
|
||||
/// backend in <see cref="PlcMultiplexerTests"/>.</para>
|
||||
///
|
||||
/// <para><b>Test strategy here:</b> exercise the connection-cap lift (>4 simultaneous
|
||||
/// upstream clients) and the BCD-rewriter integration against a real PLC-shaped backend,
|
||||
/// but issue requests on each client <i>after</i> the previous client's response has
|
||||
/// returned so the proxy's shared backend conn does not pump concurrent frames into
|
||||
/// pymodbus's broken framer. Mux correctness under truly concurrent backend traffic is
|
||||
/// proven against the stub backend in <see cref="PlcMultiplexerTests"/>.</para>
|
||||
///
|
||||
/// <para>The per-request watchdog (<c>BackendRequestTimeoutMs</c>) in
|
||||
/// <see cref="Mbproxy.Proxy.Multiplexing.PlcMultiplexer"/> defends against pymodbus's bug
|
||||
/// in production by surfacing a Modbus exception 0x0B back to upstream clients after the
|
||||
/// configured timeout — see <see cref="PlcMultiplexerTests"/> for the unit coverage.</para>
|
||||
/// </summary>
|
||||
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
|
||||
[Trait("Category", "E2E")]
|
||||
public sealed class MultiplexerE2ETests
|
||||
{
|
||||
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
|
||||
public MultiplexerE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;
|
||||
|
||||
// ── E2E 1: Five simultaneous upstream clients (connection-cap lift) ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// Headline test for Phase 9: prove that the multiplexer accepts the 5th upstream
|
||||
/// client on the same proxy port — pre-Phase-9's 1:1 model would have failed at
|
||||
/// backend connect (H2-ECOM100 cap = 4). Each client's request is serialised behind
|
||||
/// the previous client's response so the pymodbus 3.13 simulator's concurrent-frame
|
||||
/// bug never triggers; the multiplexer's connection ceiling, not its under-concurrency
|
||||
/// behaviour, is what this test proves.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_FiveSimultaneousClients_AllReadHR1072_AllGetDecoded_1234()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
};
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
// Open five simultaneous TCP connections to the proxy first (each would have used
|
||||
// a dedicated backend socket pre-Phase-9, blowing through the 4-client cap).
|
||||
var clients = new TcpClient[5];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
clients[i] = new TcpClient();
|
||||
await clients[i].ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// Now issue one read on each client, serialised. The serialisation keeps
|
||||
// pymodbus 3.13's framer in known-good single-PDU mode.
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
var master = new ModbusFactory().CreateMaster(clients[i]);
|
||||
ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
|
||||
regs[0].ShouldBe((ushort)1234, $"client #{i} must see the BCD-decoded value");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── E2E 2: Many sequential requests through 3 clients ────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Issue 21 sequential FC03 requests round-robined across three clients. Validates
|
||||
/// per-pipe forwarding, allocator re-use, and counter increments under a sustained
|
||||
/// (if not parallel) load through the multiplexed backend connection.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_TwentyOneSequential_FC03_Requests_AcrossThreeClients_AllSucceed()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
var clients = new TcpClient[3];
|
||||
var masters = new IModbusMaster[3];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
clients[i] = new TcpClient();
|
||||
await clients[i].ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
masters[i] = new ModbusFactory().CreateMaster(clients[i]);
|
||||
}
|
||||
|
||||
// 21 requests round-robin across 3 clients. Serialised so no two requests are
|
||||
// simultaneously in flight on the multiplexer's shared backend connection.
|
||||
int ok = 0;
|
||||
for (int i = 0; i < 21; i++)
|
||||
{
|
||||
_ = masters[i % 3].ReadHoldingRegisters(1, 0, 1);
|
||||
ok++;
|
||||
}
|
||||
ok.ShouldBe(21);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── E2E 3: BCD rewriter still works through the multiplexed model ────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Three clients, each writing a different decimal value to a different BCD-configured
|
||||
/// address via FC06 and reading it back. Proves the rewriter and the multiplexer's
|
||||
/// per-request <see cref="Mbproxy.Proxy.Multiplexing.InFlightRequest"/> threading
|
||||
/// preserve BCD encoding round-trips across multiple multiplexed clients.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_RewriterStillWorks_UnderMultiplexedThreeClients()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
// Configure three BCD addresses each width 16 for FC06 writes. The sim profile's
|
||||
// writable HR range is [200..209] (see DL260/dl205.json's "write" list); reads
|
||||
// outside that range succeed but writes return exception 02. We use 200/202/204.
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
["Mbproxy:BcdTags:Global:0:Address"] = "200",
|
||||
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
||||
["Mbproxy:BcdTags:Global:1:Address"] = "202",
|
||||
["Mbproxy:BcdTags:Global:1:Width"] = "16",
|
||||
["Mbproxy:BcdTags:Global:2:Address"] = "204",
|
||||
["Mbproxy:BcdTags:Global:2:Width"] = "16",
|
||||
};
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
(ushort addr, ushort val)[] cases =
|
||||
[
|
||||
(200, 1234),
|
||||
(202, 5678),
|
||||
(204, 9999),
|
||||
];
|
||||
|
||||
var clients = new TcpClient[3];
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < clients.Length; i++)
|
||||
{
|
||||
clients[i] = new TcpClient();
|
||||
await clients[i].ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
// Serialised across clients so pymodbus only sees one frame at a time.
|
||||
for (int i = 0; i < cases.Length; i++)
|
||||
{
|
||||
var master = new ModbusFactory().CreateMaster(clients[i]);
|
||||
master.WriteSingleRegister(1, cases[i].addr, cases[i].val);
|
||||
ushort[] regs = master.ReadHoldingRegisters(1, cases[i].addr, 1);
|
||||
regs[0].ShouldBe(cases[i].val,
|
||||
$"BCD round-trip for addr {cases[i].addr} via client #{i} must preserve the client's binary value");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── E2E 4: Status page reflects multiplexer state ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the status JSON surfaces the new Phase-9 mux fields: <c>inFlight</c>,
|
||||
/// <c>maxInFlight</c>, <c>txIdWraps</c>, <c>disconnectCascades</c>, <c>queueDepth</c>.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5_000)]
|
||||
public async Task E2E_StatusPage_Shows_InFlightAndMaxInFlight()
|
||||
{
|
||||
if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
int adminPort = PickFreePort();
|
||||
|
||||
var config = MakeBaseConfig(proxyPort);
|
||||
config["Mbproxy:AdminPort"] = adminPort.ToString();
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(400, TestContext.Current.CancellationToken);
|
||||
|
||||
// Drive a handful of sequential reads to bump maxInFlight ≥ 1.
|
||||
using (var client = new TcpClient())
|
||||
{
|
||||
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
var master = new ModbusFactory().CreateMaster(client);
|
||||
for (int i = 0; i < 5; i++)
|
||||
_ = master.ReadHoldingRegisters(1, 0, 1);
|
||||
}
|
||||
|
||||
// Now read /status.json and assert the new fields exist and maxInFlight ≥ 1.
|
||||
using var httpClient = new HttpClient();
|
||||
var resp = await httpClient.GetStringAsync(
|
||||
$"http://127.0.0.1:{adminPort}/status.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var plc = doc.RootElement.GetProperty("plcs")[0];
|
||||
var backend = plc.GetProperty("backend");
|
||||
|
||||
backend.TryGetProperty("inFlight", out _).ShouldBeTrue("status.json must expose backend.inFlight");
|
||||
backend.TryGetProperty("maxInFlight", out _).ShouldBeTrue("status.json must expose backend.maxInFlight");
|
||||
backend.TryGetProperty("txIdWraps", out _).ShouldBeTrue("status.json must expose backend.txIdWraps");
|
||||
backend.TryGetProperty("disconnectCascades", out _).ShouldBeTrue("status.json must expose backend.disconnectCascades");
|
||||
backend.TryGetProperty("queueDepth", out _).ShouldBeTrue("status.json must expose backend.queueDepth");
|
||||
|
||||
backend.GetProperty("maxInFlight").GetInt64()
|
||||
.ShouldBeGreaterThanOrEqualTo(1, "at least one request must have been in flight during the burst");
|
||||
}
|
||||
|
||||
// ── E2E 5: Backend disconnect cascade + recovery (uses stub backend, not pymodbus) ─
|
||||
|
||||
/// <summary>
|
||||
/// Backend disconnect cascade behaviour. Uses a stand-in stub backend rather than the
|
||||
/// pymodbus simulator so we can kill the backend mid-flight without disturbing the
|
||||
/// shared simulator fixture, AND so we are not subject to pymodbus 3.13's
|
||||
/// concurrent-frame quirk for the multi-client-in-flight scenario.
|
||||
///
|
||||
/// Timeout is 8 s (above the 5 s default) because the test exercises three sequential
|
||||
/// upstream-client connects + a Polly-paced backend reconnect, which intentionally
|
||||
/// includes 50/100/200/500/1000 ms backoffs.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 8_000)]
|
||||
public async Task E2E_BackendDisconnect_DuringInflight_CascadesUpstream_AndRecovers()
|
||||
{
|
||||
// This test uses a stand-in stub backend (not the pymodbus sim) so we can kill
|
||||
// the backend mid-flight without disturbing the shared simulator fixture.
|
||||
int backendPort = PickFreePort();
|
||||
var listener = new TcpListener(IPAddress.Loopback, backendPort);
|
||||
listener.Start();
|
||||
var serverCts = new CancellationTokenSource();
|
||||
var serverToken = serverCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!serverToken.IsCancellationRequested)
|
||||
{
|
||||
var s = await listener.AcceptSocketAsync(serverToken);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Drain forever — never respond. Test will kill us shortly.
|
||||
var buf = new byte[256];
|
||||
while (!serverToken.IsCancellationRequested)
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf, SocketFlags.None, serverToken);
|
||||
if (n == 0) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { try { s.Dispose(); } catch { } }
|
||||
}, serverToken);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}, serverToken);
|
||||
|
||||
int proxyPort = PickFreePort();
|
||||
|
||||
var config = new Dictionary<string, string?>
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "Stub",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = "127.0.0.1",
|
||||
[$"Mbproxy:Plcs:0:Port"] = backendPort.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
// Long request timeout so the watchdog doesn't fire during the test's wait window.
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "30000",
|
||||
// Aggressive backend retry so the second connect happens fast.
|
||||
["Mbproxy:Resilience:BackendConnect:MaxAttempts"] = "5",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:0"] = "50",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:1"] = "100",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:2"] = "200",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:3"] = "500",
|
||||
["Mbproxy:Resilience:BackendConnect:BackoffMs:4"] = "1000",
|
||||
};
|
||||
|
||||
var host = BuildBcdHost(config);
|
||||
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
await host.StartAsync(startCts.Token);
|
||||
await using var hd = new AsyncHostDispose(host);
|
||||
await Task.Delay(200, TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Connect three clients and start a request from each.
|
||||
var clients = new List<TcpClient>();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var c = new TcpClient();
|
||||
await c.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
await c.GetStream().WriteAsync(BuildRawFc03((ushort)(0x1000 + i), 0, 1), TestContext.Current.CancellationToken);
|
||||
clients.Add(c);
|
||||
}
|
||||
|
||||
// Kill the backend.
|
||||
await serverCts.CancelAsync();
|
||||
listener.Stop();
|
||||
|
||||
// All three should observe a clean EOF.
|
||||
foreach (var c in clients)
|
||||
{
|
||||
var buf = new byte[1];
|
||||
using var d = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
int n;
|
||||
try { n = await c.GetStream().ReadAsync(buf.AsMemory(), d.Token); }
|
||||
catch { n = 0; }
|
||||
n.ShouldBe(0, "upstream must observe a clean EOF after backend cascade");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var c in clients) c.Dispose();
|
||||
}
|
||||
|
||||
// Relaunch the stub backend on the same port.
|
||||
var newListener = new TcpListener(IPAddress.Loopback, backendPort);
|
||||
newListener.Start();
|
||||
using var newServerCts = new CancellationTokenSource();
|
||||
var newServerToken = newServerCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = await newListener.AcceptSocketAsync(newServerToken);
|
||||
var buf = new byte[256];
|
||||
while (!newServerToken.IsCancellationRequested)
|
||||
{
|
||||
int n = await s.ReceiveAsync(buf, SocketFlags.None, newServerToken);
|
||||
if (n == 0) break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}, newServerToken);
|
||||
|
||||
try
|
||||
{
|
||||
// A new upstream client should successfully connect through the multiplexer
|
||||
// (the multiplexer's backend connect logic will retry through Polly).
|
||||
using var clientD = new TcpClient();
|
||||
await clientD.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
|
||||
// The write triggers backend reconnect.
|
||||
await clientD.GetStream().WriteAsync(
|
||||
BuildRawFc03(0x2000, 0, 1),
|
||||
TestContext.Current.CancellationToken);
|
||||
// We don't expect a response from our drain-only stub — just verify the
|
||||
// multiplexer didn't drop the upstream socket immediately.
|
||||
await Task.Delay(300, TestContext.Current.CancellationToken);
|
||||
clientD.Connected.ShouldBeTrue("upstream socket should remain open after backend reconnect");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await newServerCts.CancelAsync();
|
||||
newListener.Stop();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { serverCts.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
private Dictionary<string, string?> MakeBaseConfig(int proxyPort) => new()
|
||||
{
|
||||
["Mbproxy:AdminPort"] = "0",
|
||||
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
|
||||
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
||||
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
|
||||
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
|
||||
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
|
||||
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
|
||||
};
|
||||
|
||||
private static IHost BuildBcdHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddSerilog(
|
||||
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
|
||||
dispose: false);
|
||||
builder.AddMbproxyOptions();
|
||||
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
|
||||
builder.Services.AddSingleton<ProxyWorker>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
||||
|
||||
if (int.TryParse(config["Mbproxy:AdminPort"], out int admin) && admin > 0)
|
||||
builder.AddMbproxyAdmin();
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static int PickFreePort()
|
||||
{
|
||||
var l = new TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
int p = ((IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return p;
|
||||
}
|
||||
|
||||
private static byte[] BuildRawFc03(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 sealed class AsyncHostDispose : IAsyncDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
public AsyncHostDispose(IHost host) => _host = host;
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
try { await _host.StopAsync(cts.Token); } catch { }
|
||||
_host.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
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.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.
|
||||
await c1.SendAsync(BuildFc03ReadFrame(0x0007, 0, 1), SocketFlags.None);
|
||||
await c2.SendAsync(BuildFc03ReadFrame(0x0007, 0, 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");
|
||||
|
||||
ctx.Counters.Snapshot().BackendDisconnectCascades.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Collections.Frozen;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="BcdPduPipeline"/> correlates FC03/FC04 responses through
|
||||
/// <see cref="PerPlcContext.CurrentRequest"/> (Phase 9) rather than the pre-Phase-9
|
||||
/// per-pair last-request slot. Concurrent in-flight requests from different upstream
|
||||
/// clients must decode against their own request range without cross-talk.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RewriterCorrelationTests
|
||||
{
|
||||
private static readonly BcdPduPipeline Pipeline = new();
|
||||
|
||||
private static PerPlcContext MakeContext(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 = "MuxTest",
|
||||
TagMap = map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = NullLogger.Instance,
|
||||
};
|
||||
}
|
||||
|
||||
private static InFlightRequest MakeReq(byte fc, ushort start, ushort qty)
|
||||
=> new(
|
||||
UnitId: 1, Fc: fc, StartAddress: start, Qty: qty,
|
||||
InterestedParties: Array.Empty<InterestedParty>(),
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
private static byte[] Fc03Response(params ushort[] registers)
|
||||
{
|
||||
var pdu = new byte[2 + registers.Length * 2];
|
||||
pdu[0] = 0x03;
|
||||
pdu[1] = (byte)(registers.Length * 2);
|
||||
for (int i = 0; i < registers.Length; i++)
|
||||
{
|
||||
pdu[2 + i * 2] = (byte)(registers[i] >> 8);
|
||||
pdu[2 + i * 2 + 1] = (byte)(registers[i] & 0xFF);
|
||||
}
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private static ushort ReadReg(byte[] pdu, int offsetWords)
|
||||
=> (ushort)((pdu[2 + offsetWords * 2] << 8) | pdu[2 + offsetWords * 2 + 1]);
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the rewriter reads address+qty from <see cref="PerPlcContext.CurrentRequest"/>
|
||||
/// (not from any per-pair slot) when processing an FC03 response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FC03Response_DecodedViaInFlightRequest_NotPerPairSlot()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
|
||||
// Build a response with raw BCD nibbles at address 100; no prior request was sent
|
||||
// on this context. Without CurrentRequest, the rewriter must NOT touch the bytes.
|
||||
var pdu = Fc03Response(0x1234);
|
||||
byte[] original = [.. pdu];
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), ctx);
|
||||
pdu.ShouldBe(original, "without CurrentRequest the rewriter has no correlation; bytes must pass through");
|
||||
|
||||
// Now attach a CurrentRequest that points at address 100 / qty 1.
|
||||
var withReq = ctx.WithCurrentRequest(MakeReq(fc: 0x03, start: 100, qty: 1));
|
||||
pdu = Fc03Response(0x1234);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, pdu.AsSpan(), withReq);
|
||||
ReadReg(pdu, 0).ShouldBe((ushort)1234);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two concurrent in-flight responses with different start addresses must each decode
|
||||
/// against their own request range — proves no shared-mutable-state cross-talk.
|
||||
/// Delivers them out of order to make sure ordering doesn't accidentally mask the bug.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConcurrentFC03_FromTwoUpstreams_DecodeCorrectly_NoCrossTalk()
|
||||
{
|
||||
// Tags at address 100 and 200, both 16-bit.
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16), BcdTag.Create(200, 16));
|
||||
|
||||
// Request A reads addr 100 / qty 1. Response has BCD nibbles 0x1234 (decimal 1234).
|
||||
var ctxA = ctx.WithCurrentRequest(MakeReq(0x03, 100, 1));
|
||||
var rspA = Fc03Response(0x1234);
|
||||
|
||||
// Request B reads addr 200 / qty 1. Response has BCD nibbles 0x9876 (decimal 9876).
|
||||
var ctxB = ctx.WithCurrentRequest(MakeReq(0x03, 200, 1));
|
||||
var rspB = Fc03Response(0x9876);
|
||||
|
||||
// Deliver B first, then A.
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspB.AsSpan(), ctxB);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspA.AsSpan(), ctxA);
|
||||
|
||||
ReadReg(rspB, 0).ShouldBe((ushort)9876, "B must decode against its own start address (200)");
|
||||
ReadReg(rspA, 0).ShouldBe((ushort)1234, "A must decode against its own start address (100)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FC06 responses are correlated via the address embedded in the echo, not via
|
||||
/// CurrentRequest. This test verifies two concurrent FC06 echoes from different
|
||||
/// upstreams each decode correctly when the rewriter ran their requests first.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConcurrentFC06_FromTwoUpstreams_EncodeCorrectly()
|
||||
{
|
||||
var ctx = MakeContext(BcdTag.Create(300, 16), BcdTag.Create(400, 16));
|
||||
|
||||
// Client A writes binary 1234 to address 300.
|
||||
var reqA = new byte[] { 0x06, 0x01, 0x2C, 0x04, 0xD2 }; // addr=300, value=1234
|
||||
var ctxA = ctx.WithCurrentRequest(MakeReq(0x06, 300, 1));
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, reqA.AsSpan(), ctxA);
|
||||
((reqA[3] << 8) | reqA[4]).ShouldBe(0x1234, "client A request must be BCD-encoded to 0x1234");
|
||||
|
||||
// Client B writes binary 5678 to address 400.
|
||||
var reqB = new byte[] { 0x06, 0x01, 0x90, 0x16, 0x2E }; // addr=400, value=5678
|
||||
var ctxB = ctx.WithCurrentRequest(MakeReq(0x06, 400, 1));
|
||||
Pipeline.Process(MbapDirection.RequestToBackend, ReadOnlySpan<byte>.Empty, reqB.AsSpan(), ctxB);
|
||||
((reqB[3] << 8) | reqB[4]).ShouldBe(0x5678, "client B request must be BCD-encoded to 0x5678");
|
||||
|
||||
// Now both responses echo the BCD nibbles. The rewriter must decode them.
|
||||
var rspA = new byte[] { 0x06, 0x01, 0x2C, 0x12, 0x34 };
|
||||
var rspB = new byte[] { 0x06, 0x01, 0x90, 0x56, 0x78 };
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspA.AsSpan(), ctxA);
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rspB.AsSpan(), ctxB);
|
||||
|
||||
((rspA[3] << 8) | rspA[4]).ShouldBe(1234);
|
||||
((rspB[3] << 8) | rspB[4]).ShouldBe(5678);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The rewriter must not throw if the response arrives after the upstream has gone
|
||||
/// away. The multiplexer drops responses for dead pipes silently — but the rewriter
|
||||
/// runs on the response regardless, so a dropped party should produce no exception.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ResponseForDeadUpstream_IsDropped_NoExceptionPropagates()
|
||||
{
|
||||
// Dead upstream is modeled by an empty InterestedParties list (the multiplexer
|
||||
// discovered on cascade walk that the pipe was no longer alive).
|
||||
var ctx = MakeContext(BcdTag.Create(100, 16));
|
||||
var ctxWithReq = ctx.WithCurrentRequest(MakeReq(0x03, 100, 1));
|
||||
|
||||
var rsp = Fc03Response(0x1234);
|
||||
// No assertion needed beyond "does not throw"; the rewriter is purely a bytes
|
||||
// operation and is unaware of upstream liveness.
|
||||
Should.NotThrow(() =>
|
||||
Pipeline.Process(MbapDirection.ResponseToClient, ReadOnlySpan<byte>.Empty, rsp.AsSpan(), ctxWithReq));
|
||||
ReadReg(rsp, 0).ShouldBe((ushort)1234, "the bytes were still rewritten in place");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Proxy.Multiplexing;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="TxIdAllocator"/>. Pure logic — no I/O.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TxIdAllocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Allocate_FromEmpty_Returns_NextSequential()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
alloc.TryAllocate(out ushort a).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort b).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort c).ShouldBeTrue();
|
||||
|
||||
a.ShouldBe((ushort)0);
|
||||
b.ShouldBe((ushort)1);
|
||||
c.ShouldBe((ushort)2);
|
||||
alloc.InFlightCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AfterRelease_Reuses_FreedId()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
alloc.TryAllocate(out ushort a).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort b).ShouldBeTrue();
|
||||
alloc.TryAllocate(out ushort c).ShouldBeTrue();
|
||||
|
||||
// Release the middle slot and allocate again. The next allocation should advance
|
||||
// forward from the cursor (3) and not re-use 1 until the cursor wraps and finds it free.
|
||||
alloc.Release(b);
|
||||
alloc.InFlightCount.ShouldBe(2);
|
||||
|
||||
alloc.TryAllocate(out ushort d).ShouldBeTrue();
|
||||
d.ShouldBe((ushort)3, "allocator advances the cursor; freed slot 1 reuses only after wrap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_AllocatesEveryUshort_BeforeWrapping()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
var seen = new HashSet<ushort>();
|
||||
|
||||
for (int i = 0; i < 65536; i++)
|
||||
{
|
||||
alloc.TryAllocate(out ushort id).ShouldBeTrue($"allocation {i} should succeed");
|
||||
seen.Add(id).ShouldBeTrue($"id {id} should be unique across the full 0..65535 sweep");
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(65536);
|
||||
alloc.InFlightCount.ShouldBe(65536);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_WrapsCorrectly_After0xFFFF()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
// Allocate every slot then release slot 5.
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
|
||||
alloc.Release(5);
|
||||
|
||||
// Next allocation should find slot 5 after the cursor wraps.
|
||||
alloc.TryAllocate(out ushort id).ShouldBeTrue();
|
||||
id.ShouldBe((ushort)5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allocate_WhenSaturated_ReturnsFalse_DoesNotThrow()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
|
||||
alloc.TryAllocate(out ushort id).ShouldBeFalse("saturated allocator must refuse cleanly");
|
||||
id.ShouldBe((ushort)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Release_OfNonAllocated_IsNoOp()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
|
||||
alloc.TryAllocate(out ushort a).ShouldBeTrue();
|
||||
// a == 0. Release a slot that was never allocated.
|
||||
alloc.Release(42);
|
||||
alloc.InFlightCount.ShouldBe(1, "releasing a non-allocated id must not decrement the count");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_AllocateRelease_NoDuplicateIds_Under_Parallel_Stress()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
const int taskCount = 100;
|
||||
const int opsPerTask = 1000;
|
||||
|
||||
// Each task allocates and immediately releases its id, hammering the lock.
|
||||
// If allocate ever hands out a duplicate, two tasks would see the same id.
|
||||
var observed = new System.Collections.Concurrent.ConcurrentDictionary<int, byte>();
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, taskCount).Select(_ => Task.Run(() =>
|
||||
{
|
||||
for (int i = 0; i < opsPerTask; i++)
|
||||
{
|
||||
if (!alloc.TryAllocate(out ushort id))
|
||||
continue;
|
||||
// Add a unique tag to detect a duplicate live id.
|
||||
observed.TryAdd(id, 1).ShouldBeTrue();
|
||||
observed.TryRemove(id, out byte _);
|
||||
alloc.Release(id);
|
||||
}
|
||||
})));
|
||||
|
||||
alloc.InFlightCount.ShouldBe(0, "every allocation was released; count must be back to 0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapCount_IncrementsOnEachFullWrap()
|
||||
{
|
||||
var alloc = new TxIdAllocator();
|
||||
alloc.WrapCount.ShouldBe(0);
|
||||
|
||||
// First sweep: 65536 allocations bring the cursor from 0 back to 0 → one wrap.
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
|
||||
alloc.WrapCount.ShouldBe(1);
|
||||
|
||||
// Release everything, then sweep again: should bump WrapCount to 2.
|
||||
for (ushort i = 0; ; i++)
|
||||
{
|
||||
alloc.Release(i);
|
||||
if (i == 65535) break;
|
||||
}
|
||||
for (int i = 0; i < 65536; i++)
|
||||
alloc.TryAllocate(out _).ShouldBeTrue();
|
||||
alloc.WrapCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user