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,225 @@
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Mbproxy.Options;
namespace Mbproxy.Admin;
/// <summary>
/// Hosted service that owns the Kestrel-backed admin HTTP endpoint.
///
/// <para>Lifecycle:</para>
/// <list type="bullet">
/// <item><see cref="StartAsync"/> builds a <see cref="WebApplication"/> bound to
/// <c>Mbproxy.AdminPort</c> and starts it non-blocking.</item>
/// <item>If the bind fails (port in use, etc.), logs <c>mbproxy.admin.bind.failed</c>
/// at Error and continues — the proxy listeners are unaffected.</item>
/// <item>If <c>AdminPort</c> changes via hot-reload, the current app is stopped and a
/// new one is started on the new port. Other config changes are ignored here.</item>
/// <item><see cref="StopAsync"/> shuts down the current Kestrel app with a 2 s deadline.</item>
/// </list>
///
/// <para>Routes: exactly two — <c>GET /</c> (HTML) and <c>GET /status.json</c> (JSON).</para>
/// </summary>
internal sealed partial class AdminEndpointHost : IHostedService, IAsyncDisposable
{
private readonly IOptionsMonitor<MbproxyOptions> _optionsMonitor;
private readonly StatusSnapshotBuilder _builder;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<AdminEndpointHost> _logger;
// The currently-running Kestrel app; null when stopped or when bind failed.
private WebApplication? _app;
// Protects concurrent Start/Stop calls (hot-reload + StopAsync racing).
private readonly SemaphoreSlim _lock = new(1, 1);
// Current configured port — used to detect changes on hot-reload.
private int _currentPort;
// Subscription token for IOptionsMonitor.OnChange.
private IDisposable? _optionsChangeRegistration;
public AdminEndpointHost(
IOptionsMonitor<MbproxyOptions> optionsMonitor,
StatusSnapshotBuilder builder,
ILoggerFactory loggerFactory)
{
_optionsMonitor = optionsMonitor;
_builder = builder;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<AdminEndpointHost>();
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_currentPort = _optionsMonitor.CurrentValue.AdminPort;
await StartAppAsync(_currentPort, cancellationToken).ConfigureAwait(false);
// Subscribe to config changes: if AdminPort changes, re-bind.
_optionsChangeRegistration = _optionsMonitor.OnChange(opts =>
{
int newPort = opts.AdminPort;
if (newPort == _currentPort) return; // Only care about AdminPort changes.
// Fire-and-forget: re-bind is async; we can't await in OnChange.
_ = Task.Run(async () =>
{
await _lock.WaitAsync().ConfigureAwait(false);
try
{
if (newPort == _currentPort) return; // double-check under lock
// Stop the old app.
await StopCurrentAppAsync().ConfigureAwait(false);
_currentPort = newPort;
// Start on the new port.
await StartAppAsync(newPort, CancellationToken.None).ConfigureAwait(false);
}
finally
{
_lock.Release();
}
});
});
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_optionsChangeRegistration?.Dispose();
_optionsChangeRegistration = null;
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await StopCurrentAppAsync().ConfigureAwait(false);
}
finally
{
_lock.Release();
}
}
// ── Internal helpers ─────────────────────────────────────────────────────
/// <summary>
/// Builds and starts a Kestrel <see cref="WebApplication"/> on <paramref name="port"/>.
/// On bind failure, logs the error and sets <c>_app = null</c> — does NOT throw.
/// Caller must hold <c>_lock</c> or be in a single-threaded context (StartAsync).
/// </summary>
private async Task StartAppAsync(int port, CancellationToken ct)
{
try
{
// Use CreateSlimBuilder with explicit args (empty) to avoid inheriting
// process-level environment variables like ASPNETCORE_URLS.
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
{
Args = [],
});
// Suppress Kestrel/ASP.NET Core built-in logging; forward to the outer host's
// logger factory so that admin-endpoint errors appear in the proxy's log stream.
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new ForwardingLoggerProvider(_loggerFactory));
// Explicit Kestrel listen — overrides any ASPNETCORE_URLS that leaked in.
builder.WebHost.UseKestrel(k =>
{
k.Listen(System.Net.IPAddress.Any, port);
});
var app = builder.Build();
// ── Routes ───────────────────────────────────────────────────────
app.MapGet("/", (HttpContext ctx) =>
{
var snapshot = _builder.Build();
string html = StatusHtmlRenderer.Render(snapshot);
return Results.Content(html, "text/html; charset=utf-8");
});
app.MapGet("/status.json", (HttpContext ctx) =>
{
var snapshot = _builder.Build();
string json = JsonSerializer.Serialize(snapshot, StatusJsonContext.Default.StatusResponse);
return Results.Content(json, "application/json");
});
await app.StartAsync(ct).ConfigureAwait(false);
_app = app;
LogAdminStarted(_logger, port);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Bind failed — log and continue. Proxy listeners are unaffected.
LogAdminBindFailed(_logger, port, ex.Message);
_app = null;
}
}
/// <summary>
/// Stops the current <see cref="WebApplication"/> with a 2 s deadline, then disposes it.
/// </summary>
private async Task StopCurrentAppAsync()
{
if (_app is null) return;
var app = _app;
_app = null;
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await app.StopAsync(stopCts.Token).ConfigureAwait(false);
}
catch
{
// Best-effort.
}
await app.DisposeAsync().ConfigureAwait(false);
}
// ── IAsyncDisposable ─────────────────────────────────────────────────────
public async ValueTask DisposeAsync()
{
_optionsChangeRegistration?.Dispose();
_lock.Dispose();
if (_app is { } app)
{
_app = null;
await app.DisposeAsync().ConfigureAwait(false);
}
}
// ── Logging ──────────────────────────────────────────────────────────────
[LoggerMessage(EventId = 70, EventName = "mbproxy.admin.started",
Level = LogLevel.Information,
Message = "Admin endpoint started on port {Port}")]
private static partial void LogAdminStarted(ILogger logger, int port);
[LoggerMessage(EventId = 71, EventName = "mbproxy.admin.bind.failed",
Level = LogLevel.Error,
Message = "Admin endpoint bind failed — admin page will be unavailable: Port={Port} Reason={Reason}")]
private static partial void LogAdminBindFailed(ILogger logger, int port, string reason);
// ── Inner logger provider (forwards Kestrel/ASP.NET logs to the proxy's factory) ────
private sealed class ForwardingLoggerProvider : ILoggerProvider
{
private readonly ILoggerFactory _factory;
public ForwardingLoggerProvider(ILoggerFactory factory) => _factory = factory;
public ILogger CreateLogger(string categoryName) => _factory.CreateLogger(categoryName);
public void Dispose() { }
}
}