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,196 @@
|
||||
using Mbproxy.Configuration;
|
||||
using Mbproxy.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace Mbproxy.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="ReloadPlan.Compute"/>.
|
||||
/// All tests verify the pure function logic — no side effects, no DI, no sockets.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ReloadPlanTests
|
||||
{
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static PlcOptions MakePlc(
|
||||
string name, int listenPort, string host = "127.0.0.1", int port = 502)
|
||||
=> new() { Name = name, ListenPort = listenPort, Host = host, Port = port };
|
||||
|
||||
private static MbproxyOptions MakeOptions(
|
||||
PlcOptions[] plcs,
|
||||
BcdTagListOptions? global = null)
|
||||
=> new()
|
||||
{
|
||||
Plcs = plcs,
|
||||
BcdTags = global ?? new BcdTagListOptions(),
|
||||
};
|
||||
|
||||
private static BcdTagListOptions GlobalWith(params (ushort addr, byte width)[] tags)
|
||||
=> new()
|
||||
{
|
||||
Global = tags.Select(t => new BcdTagOptions { Address = t.addr, Width = t.width }).ToList(),
|
||||
};
|
||||
|
||||
// ── 1. Add one PLC ───────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_AddOnePlc_OnlyToAddPopulated()
|
||||
{
|
||||
var current = MakeOptions([MakePlc("A", 5020)]);
|
||||
var next = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)]);
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Single(plan.ToAdd);
|
||||
Assert.Equal("B", plan.ToAdd[0].Name);
|
||||
Assert.Empty(plan.ToRemove);
|
||||
Assert.Empty(plan.ToRestart);
|
||||
Assert.Empty(plan.ToReseat);
|
||||
}
|
||||
|
||||
// ── 2. Remove one PLC ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_RemoveOnePlc_OnlyToRemovePopulated()
|
||||
{
|
||||
var current = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)]);
|
||||
var next = MakeOptions([MakePlc("A", 5020)]);
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Empty(plan.ToAdd);
|
||||
Assert.Single(plan.ToRemove);
|
||||
Assert.Equal("B", plan.ToRemove[0]);
|
||||
Assert.Empty(plan.ToRestart);
|
||||
Assert.Empty(plan.ToReseat);
|
||||
}
|
||||
|
||||
// ── 3. Changed ListenPort → goes to ToRestart, NOT ToReseat ──────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_ChangePort_GoesToToRestart_NotToReseat()
|
||||
{
|
||||
var current = MakeOptions([MakePlc("A", 5020)]);
|
||||
var next = MakeOptions([MakePlc("A", 5022)]); // ListenPort changed
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Empty(plan.ToAdd);
|
||||
Assert.Empty(plan.ToRemove);
|
||||
Assert.Single(plan.ToRestart);
|
||||
Assert.Equal("A", plan.ToRestart[0].Name);
|
||||
Assert.Equal(5022, plan.ToRestart[0].New.ListenPort);
|
||||
Assert.Empty(plan.ToReseat);
|
||||
}
|
||||
|
||||
// ── 3b. Changed Host → goes to ToRestart ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_ChangeHost_GoesToToRestart()
|
||||
{
|
||||
var current = MakeOptions([MakePlc("A", 5020, host: "10.0.0.1")]);
|
||||
var next = MakeOptions([MakePlc("A", 5020, host: "10.0.0.2")]);
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Single(plan.ToRestart);
|
||||
Assert.Empty(plan.ToReseat);
|
||||
}
|
||||
|
||||
// ── 4. Changed per-PLC tag override → goes to ToReseat ───────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_ChangePerPlcTagOverride_GoesToToReseat()
|
||||
{
|
||||
var global = GlobalWith((1072, 16));
|
||||
|
||||
// Current: PLC-A has no overrides.
|
||||
var current = MakeOptions([MakePlc("A", 5020)], global: global);
|
||||
|
||||
// Next: PLC-A adds address 1080.
|
||||
var plcWithOverride = new PlcOptions
|
||||
{
|
||||
Name = "A",
|
||||
ListenPort = 5020,
|
||||
Host = "127.0.0.1",
|
||||
Port = 502,
|
||||
BcdTags = new PlcBcdOverrides
|
||||
{
|
||||
Add = [new BcdTagOptions { Address = 1080, Width = 16 }],
|
||||
},
|
||||
};
|
||||
var next = new MbproxyOptions
|
||||
{
|
||||
Plcs = [plcWithOverride],
|
||||
BcdTags = global,
|
||||
};
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Empty(plan.ToAdd);
|
||||
Assert.Empty(plan.ToRemove);
|
||||
Assert.Empty(plan.ToRestart);
|
||||
Assert.Single(plan.ToReseat);
|
||||
Assert.Equal("A", plan.ToReseat[0].Name);
|
||||
}
|
||||
|
||||
// ── 5. Changed global tag list → all PLCs reseat, no restart ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_ChangeGlobalTagList_AllPlcsReseat_NoRestart()
|
||||
{
|
||||
var globalBefore = GlobalWith((1072, 16));
|
||||
var globalAfter = GlobalWith((1072, 16), (1080, 32)); // new 32-bit tag added
|
||||
|
||||
var current = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)], global: globalBefore);
|
||||
var next = MakeOptions([MakePlc("A", 5020), MakePlc("B", 5021)], global: globalAfter);
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Empty(plan.ToAdd);
|
||||
Assert.Empty(plan.ToRemove);
|
||||
Assert.Empty(plan.ToRestart);
|
||||
// Both PLCs should be reseated because the global tag list changed.
|
||||
Assert.Equal(2, plan.ToReseat.Count);
|
||||
Assert.Contains(plan.ToReseat, r => r.Name == "A");
|
||||
Assert.Contains(plan.ToReseat, r => r.Name == "B");
|
||||
}
|
||||
|
||||
// ── 6. No changes → all empty ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_NoChanges_AllSectionsEmpty()
|
||||
{
|
||||
var global = GlobalWith((1072, 16));
|
||||
var opts = MakeOptions([MakePlc("A", 5020)], global: global);
|
||||
|
||||
var plan = ReloadPlan.Compute(opts, opts);
|
||||
|
||||
Assert.Empty(plan.ToAdd);
|
||||
Assert.Empty(plan.ToRemove);
|
||||
Assert.Empty(plan.ToRestart);
|
||||
Assert.Empty(plan.ToReseat);
|
||||
}
|
||||
|
||||
// ── 7. Connection options propagated ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compute_ConnectionOptions_AreFromNextSnapshot()
|
||||
{
|
||||
var current = new MbproxyOptions
|
||||
{
|
||||
Plcs = [MakePlc("A", 5020)],
|
||||
Connection = new ConnectionOptions { BackendConnectTimeoutMs = 1000 },
|
||||
};
|
||||
var next = new MbproxyOptions
|
||||
{
|
||||
Plcs = [MakePlc("A", 5020)],
|
||||
Connection = new ConnectionOptions { BackendConnectTimeoutMs = 9999 },
|
||||
};
|
||||
|
||||
var plan = ReloadPlan.Compute(current, next);
|
||||
|
||||
Assert.Equal(9999, plan.Connection.BackendConnectTimeoutMs);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user