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
+92
View File
@@ -0,0 +1,92 @@
using Mbproxy.Admin;
using Mbproxy.Configuration;
using Mbproxy.Diagnostics;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Serilog;
namespace Mbproxy;
internal static class HostingExtensions
{
/// <summary>
/// Registers the <c>"Mbproxy"</c> configuration section, binds it to
/// <see cref="MbproxyOptions"/> via <c>IOptionsMonitor</c>, and registers
/// the schema-level <see cref="MbproxyOptionsValidator"/>.
///
/// Phase 06: also registers <see cref="ServiceCounters"/> (singleton) and
/// <see cref="ConfigReconciler"/> (singleton) so they can be injected into
/// <see cref="Proxy.ProxyWorker"/>.
/// </summary>
public static IHostApplicationBuilder AddMbproxyOptions(this IHostApplicationBuilder builder)
{
builder.Services
.AddOptions<MbproxyOptions>()
.BindConfiguration("Mbproxy")
.ValidateOnStart();
builder.Services.AddSingleton<
Microsoft.Extensions.Options.IValidateOptions<MbproxyOptions>,
MbproxyOptionsValidator>();
// Phase 06: service-wide counters (read by Phase 07 status page).
builder.Services.AddSingleton<ServiceCounters>();
// Phase 06: hot-reload reconciler (singleton; subscribes to IOptionsMonitor.OnChange).
builder.Services.AddSingleton<ConfigReconciler>();
return builder;
}
/// <summary>
/// Registers Phase 07 admin endpoint services:
/// <list type="bullet">
/// <item><see cref="AssemblyVersionAccessor"/> (singleton — reads version attribute once).</item>
/// <item><see cref="StatusSnapshotBuilder"/> (singleton — pure orchestration).</item>
/// <item><see cref="AdminEndpointHost"/> (hosted service — owns the Kestrel admin server).</item>
/// </list>
/// Must be called after <see cref="AddMbproxyOptions"/> and after
/// <c>AddHostedService&lt;ProxyWorker&gt;</c> (so ProxyWorker is available via DI).
/// </summary>
public static IHostApplicationBuilder AddMbproxyAdmin(this IHostApplicationBuilder builder)
{
builder.Services.AddSingleton<AssemblyVersionAccessor>();
builder.Services.AddSingleton<StatusSnapshotBuilder>();
// Register AdminEndpointHost as a singleton so ShutdownCoordinator can inject it
// directly without going through the IHostedService collection.
builder.Services.AddSingleton<AdminEndpointHost>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AdminEndpointHost>());
return builder;
}
/// <summary>
/// Configures Serilog from the <c>"Serilog"</c> configuration section,
/// with console and rolling-file sinks as defaults.
///
/// <para>Phase 08: when <paramref name="addEventLogBridge"/> is <c>true</c>, the
/// <see cref="Diagnostics.EventLogBridge"/> is added as a sub-sink for events at
/// <see cref="Serilog.Events.LogEventLevel.Error"/> and above. This flag should only be
/// set when the service is running as a Windows Service — the bridge silently ignores
/// events when the Event Log source is not registered.</para>
/// </summary>
public static IHostApplicationBuilder AddMbproxySerilog(
this IHostApplicationBuilder builder,
bool addEventLogBridge = false)
{
var cfg = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration);
if (addEventLogBridge && OperatingSystem.IsWindows())
{
cfg = cfg.WriteTo.Sink(
new EventLogBridge(enabled: true),
Serilog.Events.LogEventLevel.Error);
}
Log.Logger = cfg.CreateLogger();
builder.Services.AddSerilog(dispose: true);
return builder;
}
}