Files
wwtools/mbproxy/src/Mbproxy/HostingExtensions.cs
T
Joseph Doherty ce32c5cee8 mbproxy: Wave 1 fixes from 2026-05-14 code review
Resolves the four critical correctness defects + the ShutdownCoordinator
double-stop ordering bug called out in codereviews/2026-05-14/Overview.md.
Tests: 362 pass / 0 fail (baseline 358 + 4 new W1 regression tests).

W1.1 — Context swap on running multiplexer.
  PlcMultiplexer._ctx becomes volatile with a new ReplaceContext() method
  that re-registers the cache stats provider on the (preserved) counters.
  PlcListener exposes its multiplexer; PlcListenerSupervisor.ReplaceContextAsync
  swaps the running mux first, then disposes the old cache. Hot-reload
  tag-list changes and the cache-flush-on-reload contract now actually take
  effect on the next PDU instead of waiting for the next listener fault.

W1.2 — Coalescing factory leak.
  When the InFlightByKey factory soft-fails (allocator saturation or duplicate
  TxId), the cleanup path now TryRemoves the stub and walks every party on it
  (including late attachers) to deliver Modbus exception 0x04. Previously
  only the leader got the exception; late attachers waited forever for a
  response that no backend round-trip would ever fire.

W1.3 — Backend-reader head-of-line block.
  UpstreamPipe gains TrySendResponse for non-blocking enqueue. The per-PLC
  backend reader's fan-out loop uses it instead of awaiting SendResponseAsync,
  so a wedged upstream's full bounded response channel can no longer stall
  the single backend reader and starve every other client on that PLC. New
  responseDropForFullUpstream counter on ProxyCounters / CounterSnapshot
  records the drops.

W1.4 — Stranded outbound frames after cascade.
  TearDownBackendAsync acquires _connectGate and drains any frames left in
  _outboundChannel after the writer task faulted/cancelled, releasing their
  proxy TxIds back to the allocator. Without this, a fresh
  EnsureBackendConnectedAsync racing the cascade would send stranded frames
  with old TxIds onto the new backend socket; the responses would arrive
  with no correlation entry and the upstream peers would hang on the
  watchdog until BackendRequestTimeoutMs.

W1.5 — Delete ShutdownCoordinator (Option B).
  Drain logic moved into ProxyWorker.StopAsync. AdminEndpointHost is no
  longer registered as IHostedService; ProxyWorker drives its lifecycle
  directly so admin starts after listeners are bound and stops AFTER the
  in-flight drain (the design's documented contract). Admin is resolved
  lazily in ExecuteAsync to break the circular DI graph
  (Admin -> StatusSnapshotBuilder -> ProxyWorker). GracefulShutdownTimeoutMs
  is now read fresh from IOptionsMonitor.CurrentValue at stop time, so a
  hot-reloaded value is honoured. Removes ShutdownCoordinator + tests.

New tests:
  PlcMultiplexerTests.ReplaceContext_NewTagMap_VisibleOnNextPdu
  PlcMultiplexerTests.ReplaceContext_NewCache_NextReadGoesToBackend_NotOldCache
  UpstreamPipeTests.TrySendResponse_WhenChannelFull_ReturnsFalse_WithoutBlocking
  UpstreamPipeTests.TrySendResponse_AfterDispose_ReturnsFalse

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 05:16:13 -04:00

94 lines
3.8 KiB
C#

using Mbproxy.Admin;
using Mbproxy.Configuration;
using Mbproxy.Diagnostics;
using Mbproxy.Options;
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"/> (singleton — 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).
///
/// <para><b>Phase 12 (W1.5)</b> — <see cref="AdminEndpointHost"/> is no longer registered
/// via <c>AddHostedService</c>. <see cref="Proxy.ProxyWorker"/> drives its lifecycle
/// directly so admin start/stop ordering matches the design contract (admin starts
/// after listeners are up; admin stops AFTER the in-flight drain).</para>
/// </summary>
public static IHostApplicationBuilder AddMbproxyAdmin(this IHostApplicationBuilder builder)
{
builder.Services.AddSingleton<AssemblyVersionAccessor>();
builder.Services.AddSingleton<StatusSnapshotBuilder>();
builder.Services.AddSingleton<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;
}
}