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
+106
View File
@@ -0,0 +1,106 @@
using System.Text.Json.Serialization;
namespace Mbproxy.Admin;
// ── Wire DTOs for GET /status.json ───────────────────────────────────────────
// Field names must match design.md "Status page" tables EXACTLY (camelCase via
// JsonKnownNamingPolicy.CamelCase on the source-gen context).
/// <summary>
/// Top-level response envelope for <c>GET /status.json</c>.
/// </summary>
public sealed record StatusResponse(
ServiceFields Service,
ListenersAggregate Listeners,
IReadOnlyList<PlcStatus> Plcs);
/// <summary>Service-wide identity and reload counters.</summary>
public sealed record ServiceFields(
long UptimeSeconds,
string Version,
DateTimeOffset? ConfigLastReloadUtc,
int ConfigReloadCount,
int ConfigReloadRejectedCount);
/// <summary>Aggregate listener state across all configured PLCs.</summary>
public sealed record ListenersAggregate(int Bound, int Configured);
/// <summary>Per-PLC status row.</summary>
public sealed record PlcStatus(
string Name,
string Host,
int ListenPort,
PlcListenerStatus Listener,
PlcClientsStatus Clients,
PlcPdusStatus Pdus,
PlcBackendStatus Backend,
PlcBytesStatus Bytes);
/// <summary>Listener state sub-object.</summary>
public sealed record PlcListenerStatus(
string State,
string? LastBindError,
int RecoveryAttempts);
/// <summary>Connected-clients sub-object.</summary>
public sealed record PlcClientsStatus(
int Connected,
IReadOnlyList<ClientSnapshot> RemoteEndpoints);
/// <summary>Per-connection-pair snapshot for the status page.</summary>
public sealed record ClientSnapshot(
string Remote,
DateTimeOffset ConnectedAtUtc,
long PdusForwarded);
/// <summary>PDU counters sub-object.</summary>
public sealed record PlcPdusStatus(
long Forwarded,
FcCounts ByFc,
long RewrittenSlots,
long PartialBcdWarnings);
/// <summary>Per-function-code request counts.</summary>
public sealed record FcCounts(
long Fc03,
long Fc04,
long Fc06,
long Fc16,
long Other);
/// <summary>
/// Backend connect, exception, and multiplexer telemetry. Phase 9 added
/// <c>InFlight</c>, <c>MaxInFlight</c>, <c>TxIdWraps</c>, <c>DisconnectCascades</c>, and
/// <c>QueueDepth</c> to surface the live state of the per-PLC TxId-multiplexed connection.
/// </summary>
public sealed record PlcBackendStatus(
long ConnectsSuccess,
long ConnectsFailed,
ExceptionCounts ExceptionsByCode,
double LastRoundTripMs,
long InFlight,
long MaxInFlight,
long TxIdWraps,
long DisconnectCascades,
long QueueDepth);
/// <summary>Modbus exception counts by code.</summary>
public sealed record ExceptionCounts(
long Code01,
long Code02,
long Code03,
long Code04);
/// <summary>Byte-transfer counters.</summary>
public sealed record PlcBytesStatus(
long UpstreamIn,
long UpstreamOut);
// ── Source-generation context ─────────────────────────────────────────────────
// TreatWarningsAsErrors is on, so the context must include every reachable type.
[JsonSerializable(typeof(StatusResponse))]
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class StatusJsonContext : JsonSerializerContext;