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,88 @@
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
|
||||
namespace Mbproxy.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Validates an incoming <see cref="MbproxyOptions"/> snapshot before any state mutation
|
||||
/// is attempted. All cross-PLC checks (uniqueness, port collisions) live here.
|
||||
/// Per-PLC tag-list well-formedness is delegated to <see cref="BcdTagMapBuilder.Build"/>.
|
||||
///
|
||||
/// <para>Usage:</para>
|
||||
/// <code>
|
||||
/// if (!ReloadValidator.Validate(next, out var errors))
|
||||
/// // log errors and abort reload
|
||||
/// </code>
|
||||
/// </summary>
|
||||
internal static class ReloadValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates <paramref name="next"/>. Returns <c>true</c> when valid.
|
||||
///
|
||||
/// <para>Checks performed (in order):</para>
|
||||
/// <list type="number">
|
||||
/// <item>All PLC names are non-empty and unique (ordinal comparison).</item>
|
||||
/// <item>All <c>ListenPort</c> values are in [1, 65535] and unique.</item>
|
||||
/// <item><c>AdminPort</c> is in [1, 65535] and does not collide with any <c>ListenPort</c>.</item>
|
||||
/// <item>For each PLC, <see cref="BcdTagMapBuilder.Build"/> reports no errors.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static bool Validate(MbproxyOptions next, out IReadOnlyList<string> errors)
|
||||
{
|
||||
var errs = new List<string>();
|
||||
|
||||
// ── 1. PLC name uniqueness ────────────────────────────────────────────
|
||||
var seenNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (int i = 0; i < next.Plcs.Count; i++)
|
||||
{
|
||||
var plc = next.Plcs[i];
|
||||
if (string.IsNullOrWhiteSpace(plc.Name))
|
||||
{
|
||||
errs.Add($"Plcs[{i}]: Name must be non-empty.");
|
||||
}
|
||||
else if (!seenNames.Add(plc.Name))
|
||||
{
|
||||
errs.Add($"Plcs[{i}]: Duplicate PLC name '{plc.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. ListenPort uniqueness and range ────────────────────────────────
|
||||
var seenPorts = new Dictionary<int, string>(next.Plcs.Count); // port → PLC name
|
||||
foreach (var plc in next.Plcs)
|
||||
{
|
||||
if (plc.ListenPort is < 1 or > 65535)
|
||||
{
|
||||
errs.Add($"Plc '{plc.Name}': ListenPort {plc.ListenPort} is out of range [1, 65535].");
|
||||
}
|
||||
else if (!seenPorts.TryAdd(plc.ListenPort, plc.Name))
|
||||
{
|
||||
errs.Add($"Plc '{plc.Name}': Duplicate ListenPort {plc.ListenPort} " +
|
||||
$"(already used by '{seenPorts[plc.ListenPort]}').");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. AdminPort range and collision ─────────────────────────────────
|
||||
int adminPort = next.AdminPort;
|
||||
if (adminPort is < 1 or > 65535)
|
||||
{
|
||||
errs.Add($"AdminPort {adminPort} is out of range [1, 65535].");
|
||||
}
|
||||
else if (seenPorts.TryGetValue(adminPort, out string? clashPlc))
|
||||
{
|
||||
errs.Add($"AdminPort {adminPort} collides with ListenPort of PLC '{clashPlc}'.");
|
||||
}
|
||||
|
||||
// ── 4. Per-PLC tag-map build ──────────────────────────────────────────
|
||||
// BcdTagMapBuilder.Build is the single source of truth for tag-list
|
||||
// well-formedness; we must not duplicate its validation logic here.
|
||||
foreach (var plc in next.Plcs)
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags);
|
||||
foreach (var err in result.Errors)
|
||||
errs.Add($"Plc '{plc.Name}': BCD tag map error ({err.Kind}): {err.Message}");
|
||||
}
|
||||
|
||||
errors = errs;
|
||||
return errs.Count == 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user