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:
Joseph Doherty
2026-05-14 01:49:35 -04:00
parent 2e937228a0
commit 56eee3c563
105 changed files with 18430 additions and 0 deletions
@@ -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;
}
}