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,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() { }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user