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() { }
}
}
@@ -0,0 +1,24 @@
using System.Reflection;
namespace Mbproxy.Admin;
/// <summary>
/// Reads <see cref="AssemblyInformationalVersionAttribute"/> once at startup and caches the
/// result as a string. Used for the <c>service.version</c> field on the status page.
///
/// <para>Note: <see cref="Assembly.Location"/> is unreliable under single-file publish
/// (Phase 08). We use <c>Assembly.GetExecutingAssembly().GetCustomAttribute&lt;&gt;()</c>
/// which works correctly regardless of publish mode.</para>
/// </summary>
internal sealed class AssemblyVersionAccessor
{
/// <summary>
/// The cached informational version string, e.g. <c>"1.2.3+gitsha"</c>.
/// Falls back to <c>"0.0.0"</c> when the attribute is absent (e.g., unit-test host).
/// </summary>
public string Version { get; } =
Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? "0.0.0";
}
+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;
@@ -0,0 +1,189 @@
using System.Text;
namespace Mbproxy.Admin;
/// <summary>
/// Renders a <see cref="StatusResponse"/> as a self-contained HTML page.
///
/// <para>Constraints (from design.md Phase 07):</para>
/// <list type="bullet">
/// <item>No external assets (CSS/JS/fonts/favicons) — firewalled networks only.</item>
/// <item><c>&lt;meta http-equiv="refresh" content="5"&gt;</c> for auto-refresh.</item>
/// <item>Page weight ≤ 50 KB for a 54-PLC fleet.</item>
/// <item>Listener state colour-coded: bound=green, recovering=orange, stopped=grey.</item>
/// <item>Connected clients rendered as compact <c>[remote (n PDUs)]</c> list (not nested table).</item>
/// </list>
/// </summary>
internal static class StatusHtmlRenderer
{
private const string Css = """
body{font-family:monospace;font-size:13px;margin:1em}
h1{font-size:1.1em;margin-bottom:.3em}
.meta{color:#555;margin-bottom:.8em;font-size:12px}
table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ccc;padding:3px 6px;white-space:nowrap}
th{background:#f0f0f0;text-align:left}
tr:nth-child(even)td{background:#fafafa}
.bound{color:green;font-weight:bold}
.recovering{color:darkorange;font-weight:bold}
.stopped{color:grey}
.err{font-size:11px;color:#a00}
.clients{font-size:11px;color:#333}
""";
/// <summary>
/// Renders the status page as a complete HTML document string.
/// May allocate; intended for the status-page read path only.
/// </summary>
public static string Render(StatusResponse status)
{
var sb = new StringBuilder(4096);
sb.Append("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">");
sb.Append("<meta http-equiv=\"refresh\" content=\"5\">");
sb.Append("<title>mbproxy status</title>");
sb.Append("<style>").Append(Css).Append("</style>");
sb.Append("</head><body>");
// ── Header ────────────────────────────────────────────────────────────
sb.Append("<h1>mbproxy status</h1>");
sb.Append("<div class=\"meta\">");
sb.Append("Version: ").Append(HtmlEncode(status.Service.Version));
sb.Append(" &nbsp;|&nbsp; Uptime: ").Append(FormatUptime(status.Service.UptimeSeconds));
sb.Append(" &nbsp;|&nbsp; Listeners: ")
.Append(status.Listeners.Bound).Append('/').Append(status.Listeners.Configured)
.Append(" bound");
if (status.Service.ConfigLastReloadUtc.HasValue)
{
sb.Append(" &nbsp;|&nbsp; Last reload: ")
.Append(HtmlEncode(status.Service.ConfigLastReloadUtc.Value.ToString("yyyy-MM-dd HH:mm:ss") + "Z"));
}
sb.Append(" &nbsp;|&nbsp; Reloads: ").Append(status.Service.ConfigReloadCount);
if (status.Service.ConfigReloadRejectedCount > 0)
sb.Append(" (").Append(status.Service.ConfigReloadRejectedCount).Append(" rejected)");
sb.Append("</div>");
// ── PLC table ─────────────────────────────────────────────────────────
if (status.Plcs.Count == 0)
{
sb.Append("<p><em>No PLCs configured.</em></p>");
}
else
{
sb.Append("<table>");
sb.Append("<thead><tr>");
sb.Append("<th>Name</th><th>Host</th><th>Port</th><th>State</th>");
sb.Append("<th>Clients</th><th>PDUs fwd</th><th>FC03</th><th>FC04</th>");
sb.Append("<th>FC06</th><th>FC16</th><th>FC?</th><th>BCD slots</th>");
sb.Append("<th>Partial BCD</th><th>Ex 01</th><th>Ex 02</th><th>Ex 03</th><th>Ex 04</th>");
sb.Append("<th>RTT ms</th><th>Bytes in</th><th>Bytes out</th>");
// Phase 9: multiplexer telemetry columns.
sb.Append("<th>In-flight</th><th>Max in-flight</th><th>TxId wraps</th>");
sb.Append("<th>Cascades</th><th>Queue</th>");
sb.Append("</tr></thead><tbody>");
foreach (var plc in status.Plcs)
{
sb.Append("<tr>");
sb.Append("<td>").Append(HtmlEncode(plc.Name)).Append("</td>");
sb.Append("<td>").Append(HtmlEncode(plc.Host)).Append("</td>");
sb.Append("<td>").Append(plc.ListenPort).Append("</td>");
// State cell with colour coding
string stateClass = plc.Listener.State switch
{
"bound" => "bound",
"recovering" => "recovering",
_ => "stopped",
};
sb.Append("<td><span class=\"").Append(stateClass).Append("\">")
.Append(HtmlEncode(plc.Listener.State)).Append("</span>");
if (plc.Listener.State == "recovering" && plc.Listener.LastBindError is { } err)
{
sb.Append("<br><span class=\"err\">")
.Append(HtmlEncode(err))
.Append(" (attempt ").Append(plc.Listener.RecoveryAttempts).Append(")")
.Append("</span>");
}
sb.Append("</td>");
// Connected clients
sb.Append("<td><span class=\"clients\">");
sb.Append(plc.Clients.Connected);
if (plc.Clients.RemoteEndpoints.Count > 0)
{
sb.Append("<br>");
bool first = true;
foreach (var c in plc.Clients.RemoteEndpoints)
{
if (!first) sb.Append(", ");
sb.Append(HtmlEncode(c.Remote))
.Append(" (").Append(c.PdusForwarded).Append(')');
first = false;
}
}
sb.Append("</span></td>");
// Counter cells
sb.Append("<td>").Append(plc.Pdus.Forwarded).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc03).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc04).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc06).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Fc16).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.ByFc.Other).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.RewrittenSlots).Append("</td>");
sb.Append("<td>").Append(plc.Pdus.PartialBcdWarnings).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code01).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code02).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code03).Append("</td>");
sb.Append("<td>").Append(plc.Backend.ExceptionsByCode.Code04).Append("</td>");
sb.Append("<td>").Append(plc.Backend.LastRoundTripMs.ToString("F1")).Append("</td>");
sb.Append("<td>").Append(plc.Bytes.UpstreamIn).Append("</td>");
sb.Append("<td>").Append(plc.Bytes.UpstreamOut).Append("</td>");
// Phase 9: multiplexer telemetry cells.
sb.Append("<td>").Append(plc.Backend.InFlight).Append("</td>");
sb.Append("<td>").Append(plc.Backend.MaxInFlight).Append("</td>");
sb.Append("<td>").Append(plc.Backend.TxIdWraps).Append("</td>");
sb.Append("<td>").Append(plc.Backend.DisconnectCascades).Append("</td>");
sb.Append("<td>").Append(plc.Backend.QueueDepth).Append("</td>");
sb.Append("</tr>");
}
sb.Append("</tbody></table>");
}
sb.Append("</body></html>");
return sb.ToString();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string FormatUptime(long seconds)
{
var ts = TimeSpan.FromSeconds(seconds);
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h {ts.Minutes:D2}m {ts.Seconds:D2}s";
if (ts.TotalMinutes >= 1)
return $"{ts.Minutes}m {ts.Seconds:D2}s";
return $"{seconds}s";
}
private static string HtmlEncode(string s)
{
// Fast path: no special chars.
if (!ContainsHtmlSpecial(s)) return s;
return s
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;");
}
private static bool ContainsHtmlSpecial(string s)
{
foreach (char c in s)
if (c is '&' or '<' or '>' or '"') return true;
return false;
}
}
@@ -0,0 +1,157 @@
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Multiplexing;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Options;
namespace Mbproxy.Admin;
/// <summary>
/// Pure orchestration: reads live state from injected singletons and builds a
/// <see cref="StatusResponse"/> for <c>GET /</c> and <c>GET /status.json</c>.
///
/// <para>No I/O; no side effects. Constructed once via DI; <see cref="Build"/> is the
/// only operation and may be called on any thread at any time.</para>
/// </summary>
internal sealed class StatusSnapshotBuilder
{
private readonly IOptionsMonitor<MbproxyOptions> _options;
private readonly ServiceCounters _serviceCounters;
private readonly AssemblyVersionAccessor _version;
private readonly ProxyWorker _proxyWorker;
public StatusSnapshotBuilder(
IOptionsMonitor<MbproxyOptions> options,
ServiceCounters serviceCounters,
AssemblyVersionAccessor version,
ProxyWorker proxyWorker)
{
_options = options;
_serviceCounters = serviceCounters;
_version = version;
_proxyWorker = proxyWorker;
}
/// <summary>
/// Builds a point-in-time <see cref="StatusResponse"/>.
/// Each counter is read atomically; no locks are held across the build.
/// </summary>
public StatusResponse Build()
{
var opts = _options.CurrentValue;
var now = DateTimeOffset.UtcNow;
var started = _serviceCounters.StartedAtUtc;
var uptime = (long)(now - started).TotalSeconds;
var supervisors = _proxyWorker.Supervisors;
// ── Build per-PLC status rows ─────────────────────────────────────────
var plcStatuses = new List<PlcStatus>(opts.Plcs.Count);
int boundCount = 0;
foreach (var plc in opts.Plcs)
{
supervisors.TryGetValue(plc.Name, out var supervisor);
// Supervisor state
SupervisorSnapshot? snap = supervisor?.Snapshot();
string stateStr = snap?.State switch
{
SupervisorState.Bound => "bound",
SupervisorState.Recovering => "recovering",
_ => "stopped",
};
if (snap?.State == SupervisorState.Bound) boundCount++;
// Per-client snapshots
var activeUpstreams = supervisor?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>();
var clientSnapshots = activeUpstreams
.Select(p => new ClientSnapshot(
Remote: p.RemoteEp?.ToString() ?? p.RemoteEp?.Address.ToString() ?? "?",
ConnectedAtUtc: p.ConnectedAtUtc,
PdusForwarded: p.PdusForwardedCount))
.ToList();
// Counter snapshot
var counters = supervisor?.CurrentCounters.Snapshot()
?? new CounterSnapshot(
PdusForwarded: 0,
Fc03: 0,
Fc04: 0,
Fc06: 0,
Fc16: 0,
FcOther: 0,
RewrittenSlots: 0,
PartialBcdWarnings: 0,
InvalidBcdWarnings: 0,
BackendException01: 0,
BackendException02: 0,
BackendException03: 0,
BackendException04: 0,
BackendExceptionOther: 0,
BytesUpstreamIn: 0,
BytesUpstreamOut: 0,
RecoveryAttempts: 0,
LastBindError: null,
LastRoundTripMs: 0.0,
ConnectsSuccess: 0,
ConnectsFailed: 0,
InFlightCount: 0,
MaxInFlight: 0,
TxIdWraps: 0,
BackendDisconnectCascades: 0,
BackendQueueDepth: 0);
// Phase 08: ConnectsSuccess / ConnectsFailed are now tracked in ProxyCounters.
long connectsSuccess = counters.ConnectsSuccess;
long connectsFailed = counters.ConnectsFailed;
plcStatuses.Add(new PlcStatus(
Name: plc.Name,
Host: plc.Host,
ListenPort: plc.ListenPort,
Listener: new PlcListenerStatus(
State: stateStr,
LastBindError: snap?.LastBindError,
RecoveryAttempts: snap?.RecoveryAttempts ?? 0),
Clients: new PlcClientsStatus(
Connected: clientSnapshots.Count,
RemoteEndpoints: clientSnapshots),
Pdus: new PlcPdusStatus(
Forwarded: counters.PdusForwarded,
ByFc: new FcCounts(counters.Fc03, counters.Fc04, counters.Fc06, counters.Fc16, counters.FcOther),
RewrittenSlots: counters.RewrittenSlots,
PartialBcdWarnings: counters.PartialBcdWarnings),
Backend: new PlcBackendStatus(
ConnectsSuccess: connectsSuccess,
ConnectsFailed: connectsFailed,
ExceptionsByCode: new ExceptionCounts(
counters.BackendException01,
counters.BackendException02,
counters.BackendException03,
counters.BackendException04),
LastRoundTripMs: counters.LastRoundTripMs,
InFlight: counters.InFlightCount,
MaxInFlight: counters.MaxInFlight,
TxIdWraps: counters.TxIdWraps,
DisconnectCascades: counters.BackendDisconnectCascades,
QueueDepth: counters.BackendQueueDepth),
Bytes: new PlcBytesStatus(
UpstreamIn: counters.BytesUpstreamIn,
UpstreamOut: counters.BytesUpstreamOut)));
}
// ── Service-wide fields ───────────────────────────────────────────────
var service = new ServiceFields(
UptimeSeconds: uptime,
Version: _version.Version,
ConfigLastReloadUtc: _serviceCounters.LastReloadUtc,
ConfigReloadCount: _serviceCounters.ReloadAppliedCount,
ConfigReloadRejectedCount: _serviceCounters.ReloadRejectedCount);
var listeners = new ListenersAggregate(
Bound: boundCount,
Configured: opts.Plcs.Count);
return new StatusResponse(service, listeners, plcStatuses);
}
}